mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-01 20:03:39 +00:00
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:
48
cmd/hook.go
48
cmd/hook.go
@@ -186,23 +186,24 @@ Gitea or set your environment appropriately.`, "")
|
||||
|
||||
// the environment is set by serv command
|
||||
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
|
||||
username := os.Getenv(repo_module.EnvRepoUsername)
|
||||
reponame := os.Getenv(repo_module.EnvRepoName)
|
||||
ownerName := os.Getenv(repo_module.EnvRepoUsername)
|
||||
repoName := os.Getenv(repo_module.EnvRepoName)
|
||||
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
|
||||
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
|
||||
deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64)
|
||||
actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64)
|
||||
|
||||
hookOptions := private.HookOptions{
|
||||
UserID: userID,
|
||||
IsWiki: isWiki,
|
||||
|
||||
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
|
||||
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
|
||||
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
|
||||
GitPushOptions: pushOptions(),
|
||||
PullRequestID: prID,
|
||||
DeployKeyID: deployKeyID,
|
||||
ActionsTaskID: actionsTaskID,
|
||||
IsWiki: isWiki,
|
||||
|
||||
PullRequestID: prID,
|
||||
|
||||
UserID: userID,
|
||||
UserName: os.Getenv(repo_module.EnvPusherName),
|
||||
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
@@ -257,7 +258,7 @@ Gitea or set your environment appropriately.`, "")
|
||||
hookOptions.OldCommitIDs = oldCommitIDs
|
||||
hookOptions.NewCommitIDs = newCommitIDs
|
||||
hookOptions.RefFullNames = refFullNames
|
||||
extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
|
||||
extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
|
||||
if extra.HasError() {
|
||||
return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error)
|
||||
}
|
||||
@@ -283,7 +284,7 @@ Gitea or set your environment appropriately.`, "")
|
||||
|
||||
fmt.Fprintf(out, " Checking %d references\n", count)
|
||||
|
||||
extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
|
||||
extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
|
||||
if extra.HasError() {
|
||||
return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error)
|
||||
}
|
||||
@@ -353,18 +354,21 @@ Gitea or set your environment appropriately.`, "")
|
||||
repoName := os.Getenv(repo_module.EnvRepoName)
|
||||
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
|
||||
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
|
||||
pusherName := os.Getenv(repo_module.EnvPusherName)
|
||||
|
||||
hookOptions := private.HookOptions{
|
||||
UserName: pusherName,
|
||||
UserID: pusherID,
|
||||
IsWiki: isWiki,
|
||||
|
||||
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
|
||||
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
|
||||
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
|
||||
GitPushOptions: pushOptions(),
|
||||
PullRequestID: prID,
|
||||
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
|
||||
IsWiki: isWiki,
|
||||
|
||||
PullRequestID: prID,
|
||||
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
|
||||
|
||||
UserID: pusherID,
|
||||
UserName: os.Getenv(repo_module.EnvPusherName),
|
||||
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
|
||||
}
|
||||
|
||||
oldCommitIDs := make([]string, 0, hookBatchSize)
|
||||
@@ -481,7 +485,6 @@ Gitea or set your environment appropriately.`, "")
|
||||
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
|
||||
repoName := os.Getenv(repo_module.EnvRepoName)
|
||||
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
|
||||
pusherName := os.Getenv(repo_module.EnvPusherName)
|
||||
|
||||
// 1. Version and features negotiation.
|
||||
// S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n)
|
||||
@@ -553,10 +556,13 @@ Gitea or set your environment appropriately.`, "")
|
||||
// S: ... ...
|
||||
// S: flush-pkt
|
||||
hookOptions := private.HookOptions{
|
||||
UserName: pusherName,
|
||||
UserID: pusherID,
|
||||
IsWiki: isWiki,
|
||||
|
||||
GitPushOptions: make(map[string]string),
|
||||
IsWiki: isWiki,
|
||||
|
||||
UserID: pusherID,
|
||||
UserName: os.Getenv(repo_module.EnvPusherName),
|
||||
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
|
||||
}
|
||||
hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize)
|
||||
hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize)
|
||||
|
||||
23
cmd/serv.go
23
cmd/serv.go
@@ -256,7 +256,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
if results.IsWiki {
|
||||
return fail(ctx, "LFS Transfer is not supported for wikis", "")
|
||||
}
|
||||
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
|
||||
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,7 +270,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
lfsTokenHref := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName))
|
||||
|
||||
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
|
||||
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -314,15 +314,20 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
command.Env = append(command.Env, os.Environ()...)
|
||||
command.Env = append(command.Env,
|
||||
repo_module.EnvRepoIsWiki+"="+strconv.FormatBool(results.IsWiki),
|
||||
repo_module.EnvRepoName+"="+results.RepoName,
|
||||
|
||||
repo_module.EnvRepoUsername+"="+results.OwnerName,
|
||||
repo_module.EnvRepoName+"="+results.RepoName,
|
||||
repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
|
||||
|
||||
repo_module.EnvKeyID+"="+strconv.FormatInt(results.PublicKeyID, 10),
|
||||
|
||||
repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
|
||||
repo_module.EnvPusherName+"="+results.UserName,
|
||||
repo_module.EnvPusherEmail+"="+results.UserEmail,
|
||||
repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
|
||||
repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
|
||||
repo_module.EnvPusherExtDoerData+"="+results.UserExtDoerData,
|
||||
|
||||
repo_module.EnvPRID+"="+strconv.Itoa(0),
|
||||
repo_module.EnvDeployKeyID+"="+strconv.FormatInt(results.DeployKeyID, 10),
|
||||
repo_module.EnvKeyID+"="+strconv.FormatInt(results.KeyID, 10),
|
||||
|
||||
repo_module.EnvAppURL+"="+setting.AppURL,
|
||||
)
|
||||
// to avoid breaking, here only use the minimal environment variables for the "gitea serv" command.
|
||||
@@ -334,8 +339,8 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
|
||||
// Update user key activity.
|
||||
if results.KeyID > 0 {
|
||||
if err = private.UpdatePublicKeyInRepo(ctx, results.KeyID, results.RepoID); err != nil {
|
||||
if results.PublicKeyID > 0 {
|
||||
if err = private.UpdatePublicKeyInRepo(ctx, results.PublicKeyID, results.RepoID); err != nil {
|
||||
return fail(ctx, "Failed to update public key", "UpdatePublicKeyInRepo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(349, "Expand action_schedule content column", v28.ExpandActionScheduleContent),
|
||||
newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease),
|
||||
newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer),
|
||||
newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
38
modelmigration/v28/v352.go
Normal file
38
modelmigration/v28/v352.go
Normal 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
|
||||
}
|
||||
@@ -27,14 +27,7 @@ func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error
|
||||
return err
|
||||
}
|
||||
for _, attempt := range attempts {
|
||||
if attempt.TriggerUserID == user_model.ActionsUserID {
|
||||
attempt.TriggerUser = user_model.NewActionsUser()
|
||||
} else {
|
||||
attempt.TriggerUser = users[attempt.TriggerUserID]
|
||||
if attempt.TriggerUser == nil {
|
||||
attempt.TriggerUser = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
attempt.TriggerUser = user_model.GetPossibleUserFromMap(attempt.TriggerUserID, users)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,67 +215,6 @@ func (err ErrKeyAccessDenied) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
|
||||
type ErrDeployKeyNotExist struct {
|
||||
ID int64
|
||||
KeyID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
|
||||
func IsErrDeployKeyNotExist(err error) bool {
|
||||
_, ok := err.(ErrDeployKeyNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrDeployKeyNotExist) Error() string {
|
||||
return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
|
||||
}
|
||||
|
||||
func (err ErrDeployKeyNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
|
||||
type ErrDeployKeyAlreadyExist struct {
|
||||
KeyID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
|
||||
func IsErrDeployKeyAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrDeployKeyAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrDeployKeyAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
|
||||
}
|
||||
|
||||
func (err ErrDeployKeyAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
|
||||
type ErrDeployKeyNameAlreadyUsed struct {
|
||||
RepoID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
|
||||
func IsErrDeployKeyNameAlreadyUsed(err error) bool {
|
||||
_, ok := err.(ErrDeployKeyNameAlreadyUsed)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrDeployKeyNameAlreadyUsed) Error() string {
|
||||
return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
|
||||
}
|
||||
|
||||
func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error.
|
||||
type ErrSSHInvalidTokenSignature struct {
|
||||
Wrapped error
|
||||
|
||||
@@ -89,6 +89,36 @@ func addPublicKey(ctx context.Context, key *PublicKey) (err error) {
|
||||
return appendAuthorizedKeysToFile(key)
|
||||
}
|
||||
|
||||
// FindOrAddDeployPublicKey returns the shared public key that deploy keys of the given content link to, adding it on first use.
|
||||
func FindOrAddDeployPublicKey(ctx context.Context, content string) (*PublicKey, error) {
|
||||
fingerprint, err := CalcFingerprint(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if exist {
|
||||
if pkey.Type != KeyTypeDeploy {
|
||||
return nil, ErrKeyAlreadyExist{0, fingerprint, ""}
|
||||
}
|
||||
return pkey, nil
|
||||
}
|
||||
|
||||
pkey = &PublicKey{
|
||||
Mode: perm.AccessModeNone,
|
||||
Type: KeyTypeDeploy,
|
||||
Name: "(DeployKey)",
|
||||
Content: content,
|
||||
Fingerprint: fingerprint,
|
||||
}
|
||||
if err = addPublicKey(ctx, pkey); err != nil {
|
||||
return nil, fmt.Errorf("addPublicKey: %w", err)
|
||||
}
|
||||
return pkey, nil
|
||||
}
|
||||
|
||||
// AddPublicKey adds new public key to database and authorized_keys file.
|
||||
func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) {
|
||||
log.Trace(content)
|
||||
|
||||
@@ -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
models/deploykey/deploykey.go
Normal file
99
models/deploykey/deploykey.go
Normal 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
models/deploykey/deploykey_ssh.go
Normal file
75
models/deploykey/deploykey_ssh.go
Normal 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
models/deploykey/deploykey_token.go
Normal file
76
models/deploykey/deploykey_token.go
Normal 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
models/deploykey/deploykey_token_test.go
Normal file
52
models/deploykey/deploykey_token_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deploykey
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeployToken(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, err := AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, key.IsReadOnly())
|
||||
assert.Len(t, key.Token, len(DeployTokenPrefix)+deployTokenLength)
|
||||
|
||||
got, err := VerifyDeployKeyToken(t.Context(), key.Token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, key.ID, got.ID)
|
||||
assert.Empty(t, got.Token, "the token itself is never stored")
|
||||
|
||||
_, err = VerifyDeployKeyToken(t.Context(), "not-a-token")
|
||||
assert.True(t, IsErrDeployKeyNotExist(err))
|
||||
|
||||
_, err = AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
|
||||
assert.True(t, IsErrDeployKeyNameAlreadyUsed(err))
|
||||
|
||||
regenerated, err := RegenerateDeployKeyToken(t.Context(), 1, key.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, key.Name, regenerated.Name)
|
||||
assert.Equal(t, key.Mode, regenerated.Mode)
|
||||
assert.False(t, unittest.AssertExistsAndLoadBean(t, &DeployKey{ID: key.ID}).HasUsed(), "regenerating is not a use")
|
||||
|
||||
_, err = VerifyDeployKeyToken(t.Context(), key.Token)
|
||||
assert.True(t, IsErrDeployKeyNotExist(err), "the old token stops working")
|
||||
_, err = VerifyDeployKeyToken(t.Context(), regenerated.Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// an SSH deploy-key has no token to regenerate
|
||||
sshKey := &DeployKey{RepoID: 1, KeyType: KeyTypeSSH, Name: "ssh"}
|
||||
require.NoError(t, db.Insert(t.Context(), sshKey))
|
||||
_, err = RegenerateDeployKeyToken(t.Context(), 1, sshKey.ID)
|
||||
assert.True(t, IsErrDeployKeyNotExist(err))
|
||||
}
|
||||
71
models/deploykey/error.go
Normal file
71
models/deploykey/error.go
Normal 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
models/deploykey/main_test.go
Normal file
14
models/deploykey/main_test.go
Normal 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
|
||||
}
|
||||
@@ -748,4 +748,18 @@
|
||||
config: "{}"
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 113
|
||||
repo_id: 19
|
||||
type: 1
|
||||
config: "{}"
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
id: 114
|
||||
repo_id: 20
|
||||
type: 1
|
||||
config: "{}"
|
||||
created_unix: 946684810
|
||||
|
||||
# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -383,12 +384,39 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
// getDeployKeyRepoPermission returns the permissions that a deploy key grants on a repository.
|
||||
// A key only ever reaches the git data of the one repository it was added to, at its own access mode.
|
||||
func getDeployKeyRepoPermission(ctx context.Context, repo *repo_model.Repository, keyID int64) (perm Permission, err error) {
|
||||
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, keyID)
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
return perm, nil // the key belongs to another repository, so it grants nothing here
|
||||
}
|
||||
return perm, err
|
||||
}
|
||||
if err = repo.LoadUnits(ctx); err != nil {
|
||||
return perm, err
|
||||
}
|
||||
|
||||
perm.units = repo.Units
|
||||
perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
|
||||
for _, u := range repo.Units {
|
||||
if u.Type == unit.TypeCode || u.Type == unit.TypeWiki { // a deploy-key only ever reaches git data
|
||||
perm.unitsMode[u.Type] = key.Mode
|
||||
}
|
||||
}
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
// GetDoerRepoPermission returns the repository permission for the current actor,
|
||||
// dispatching to GetActionsUserRepoPermission when the actor is an Actions token user.
|
||||
// dispatching to the credential-scoped permissions when the actor is a token or key user.
|
||||
func GetDoerRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (Permission, error) {
|
||||
if taskID, ok := user_model.GetActionsUserTaskID(user); ok {
|
||||
return GetActionsUserRepoPermission(ctx, repo, user, taskID)
|
||||
}
|
||||
if keyID, ok := user_model.GetDeployKeyUserDeployKeyID(user); ok {
|
||||
return getDeployKeyRepoPermission(ctx, repo, keyID)
|
||||
}
|
||||
return GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,7 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
|
||||
|
||||
// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size
|
||||
func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
|
||||
// ghost user was deleted, Gitea actions is a bot user, 0 means the user should be a virtual user
|
||||
// which comes from git configure information
|
||||
if u.IsGhost() || u.IsGiteaActions() || u.ID <= 0 {
|
||||
if u.ID <= 0 {
|
||||
return avatars.DefaultAvatarLink()
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,11 @@ type User struct {
|
||||
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
|
||||
Theme string `xorm:"NOT NULL DEFAULT ''"`
|
||||
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
|
||||
// When the user model is used as a doer (all existing code does so), the doer can have extra details.
|
||||
// * Actions task doer needs to bind to the task
|
||||
// * Deploy-key doer needs to bind to the key
|
||||
ExtDoerData ExtDoerData `xorm:"-"`
|
||||
}
|
||||
|
||||
// Meta defines the meta information of a user, to be stored in the K/V table
|
||||
@@ -418,9 +423,9 @@ func (u *User) IsOrganization() bool {
|
||||
return u.Type == UserTypeOrganization
|
||||
}
|
||||
|
||||
// IsIndividual returns true if user is actually a individual user.
|
||||
// IsIndividual returns true if user is actually an individual user.
|
||||
func (u *User) IsIndividual() bool {
|
||||
return u.Type == UserTypeIndividual
|
||||
return u.ID > 0 && u.Type == UserTypeIndividual
|
||||
}
|
||||
|
||||
// IsTypeBot returns whether the user is of type bot
|
||||
@@ -513,9 +518,8 @@ func (u *User) GitName() string {
|
||||
}
|
||||
|
||||
// IsMailable checks if a user is eligible to receive emails.
|
||||
// System users like Ghost and Gitea Actions are excluded.
|
||||
func (u *User) IsMailable() bool {
|
||||
return u.IsActive && !u.IsGiteaActions() && !u.IsGhost()
|
||||
return u.ID > 0 && u.IsActive && u.IsIndividual()
|
||||
}
|
||||
|
||||
// IsUserExist checks if given username exist,
|
||||
@@ -551,10 +555,11 @@ type globalVarsStruct struct {
|
||||
emailToReplacer *strings.Replacer
|
||||
emailRegexp *regexp.Regexp
|
||||
systemUserNewFuncs map[int64]func() *User
|
||||
systemUserNameIdMap map[string]int64
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() *globalVarsStruct {
|
||||
return &globalVarsStruct{
|
||||
ret := &globalVarsStruct{
|
||||
// Note: The set of characters here can safely expand without a breaking change,
|
||||
// but characters removed from this set can cause user account linking to break
|
||||
customCharsReplacement: strings.NewReplacer("Æ", "AE"),
|
||||
@@ -573,12 +578,17 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
|
||||
";", "",
|
||||
),
|
||||
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
|
||||
|
||||
systemUserNewFuncs: map[int64]func() *User{
|
||||
GhostUserID: NewGhostUser,
|
||||
ActionsUserID: NewActionsUser,
|
||||
},
|
||||
}
|
||||
|
||||
userFuncs := []func() *User{NewGhostUser, NewActionsUser, NewDeployKeyUser}
|
||||
ret.systemUserNewFuncs = map[int64]func() *User{}
|
||||
ret.systemUserNameIdMap = map[string]int64{}
|
||||
for _, fn := range userFuncs {
|
||||
u := fn()
|
||||
ret.systemUserNewFuncs[u.ID] = fn
|
||||
ret.systemUserNameIdMap[u.LowerName] = u.ID
|
||||
}
|
||||
return ret
|
||||
})
|
||||
|
||||
// NormalizeUserName only takes the name part if it is an email address, transforms it diacritics to ASCII characters.
|
||||
@@ -1023,7 +1033,7 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
||||
return users, err
|
||||
}
|
||||
|
||||
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
|
||||
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
|
||||
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
|
||||
if id < 0 {
|
||||
if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok {
|
||||
|
||||
46
models/user/user_extdata.go
Normal file
46
models/user/user_extdata.go
Normal 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
|
||||
}
|
||||
@@ -31,18 +31,15 @@ func GetUsersMapByIDs(ctx context.Context, userIDs []int64) (map[int64]*User, er
|
||||
}
|
||||
|
||||
func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
|
||||
switch userID {
|
||||
case GhostUserID:
|
||||
return NewGhostUser()
|
||||
case ActionsUserID:
|
||||
return NewActionsUser()
|
||||
case 0:
|
||||
if userID == 0 {
|
||||
return nil
|
||||
default:
|
||||
user, ok := usererMaps[userID]
|
||||
if !ok {
|
||||
return NewGhostUser()
|
||||
}
|
||||
return user
|
||||
}
|
||||
if newFunc, ok := globalVars().systemUserNewFuncs[userID]; ok {
|
||||
return newFunc()
|
||||
}
|
||||
user, ok := usererMaps[userID]
|
||||
if !ok {
|
||||
return NewGhostUser()
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/structs"
|
||||
@@ -32,59 +32,86 @@ func (u *User) IsGhost() bool {
|
||||
return u.ID == GhostUserID && u.Name == GhostUserName
|
||||
}
|
||||
|
||||
// newSystemUser creates and returns a fake user for system use.
|
||||
// The builtin username can be wrapped in parentheses to avoid conflicts with real usernames.
|
||||
func newSystemUser(id int64, name, fullName string) *User {
|
||||
return &User{
|
||||
ID: id,
|
||||
Name: name,
|
||||
LowerName: strings.ToLower(name),
|
||||
IsActive: true,
|
||||
FullName: fullName,
|
||||
Type: UserTypeBot,
|
||||
Visibility: structs.VisibleTypePublic,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
ActionsUserID int64 = -2
|
||||
ActionsUserName = "gitea-actions"
|
||||
ActionsUserEmail = "teabot@gitea.io"
|
||||
ActionsUserID int64 = -2
|
||||
DeployKeyUserID int64 = -3
|
||||
)
|
||||
|
||||
// NewActionsUser creates and returns a fake user for running the actions.
|
||||
func NewActionsUser() *User {
|
||||
return &User{
|
||||
ID: ActionsUserID,
|
||||
Name: ActionsUserName,
|
||||
LowerName: ActionsUserName,
|
||||
IsActive: true,
|
||||
FullName: "Gitea Actions",
|
||||
Email: ActionsUserEmail,
|
||||
KeepEmailPrivate: true,
|
||||
LoginName: ActionsUserName,
|
||||
Type: UserTypeBot,
|
||||
Visibility: structs.VisibleTypePublic,
|
||||
return newSystemUser(ActionsUserID, "gitea-actions", "Gitea Actions")
|
||||
}
|
||||
|
||||
func GetActionsUserTaskID(u *User) (int64, bool) {
|
||||
if u == nil || u.ExtDoerData == nil || u.ID != ActionsUserID {
|
||||
return 0, false
|
||||
}
|
||||
extData := u.ExtDoerData.(*extDoerGiteaActions) //nolint:forcetypeassert // must be valid
|
||||
return extData.TaskID, true
|
||||
}
|
||||
|
||||
func NewActionsUserWithTaskID(id int64) *User {
|
||||
u := NewActionsUser()
|
||||
// LoginName is for only internal usage in this case, so it can be moved to other fields in the future
|
||||
u.LoginSource = -1
|
||||
u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10)
|
||||
u.ExtDoerData = &extDoerGiteaActions{TaskID: id}
|
||||
return u
|
||||
}
|
||||
|
||||
func GetActionsUserTaskID(u *User) (int64, bool) {
|
||||
if u == nil || u.ID != ActionsUserID {
|
||||
return 0, false
|
||||
}
|
||||
prefix, payload, _ := strings.Cut(u.LoginName, "/")
|
||||
if prefix != "@"+ActionsUserName {
|
||||
return 0, false
|
||||
} else if taskID, err := strconv.ParseInt(payload, 10, 64); err == nil {
|
||||
return taskID, true
|
||||
}
|
||||
return 0, false
|
||||
func NewDeployKeyUser() *User {
|
||||
return newSystemUser(DeployKeyUserID, "(deploy-key)", "Deploy Key")
|
||||
}
|
||||
|
||||
func (u *User) IsGiteaActions() bool {
|
||||
return u != nil && u.ID == ActionsUserID
|
||||
func GetDeployKeyUserDeployKeyID(u *User) (int64, bool) {
|
||||
// ok, the function name seems wordy, it is intentionally to distinguish from other "keys" like "public key id"
|
||||
// it was a mess in the "pre-receive" hook code
|
||||
if u == nil || u.ExtDoerData == nil || u.ID != DeployKeyUserID {
|
||||
return 0, false
|
||||
}
|
||||
extData := u.ExtDoerData.(*extDoerDeployKey) //nolint:forcetypeassert // must be valid
|
||||
return extData.DeployKeyID, true
|
||||
}
|
||||
|
||||
func NewDeployKeyUserWithKeyID(id int64) *User {
|
||||
u := NewDeployKeyUser()
|
||||
u.ExtDoerData = &extDoerDeployKey{DeployKeyID: id}
|
||||
return u
|
||||
}
|
||||
|
||||
func GetSystemUserByName(name string) *User {
|
||||
if strings.EqualFold(name, GhostUserName) {
|
||||
return NewGhostUser()
|
||||
}
|
||||
if strings.EqualFold(name, ActionsUserName) {
|
||||
return NewActionsUser()
|
||||
lowerName := strings.ToLower(name)
|
||||
uid := globalVars().systemUserNameIdMap[lowerName]
|
||||
if fn := globalVars().systemUserNewFuncs[uid]; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDoerUser(ctx context.Context, id int64, extDoerData string) (u *User, _ error) {
|
||||
if id > 0 {
|
||||
return GetUserByID(ctx, id)
|
||||
}
|
||||
switch id {
|
||||
case ActionsUserID:
|
||||
u = NewActionsUser()
|
||||
u.ExtDoerData = &extDoerGiteaActions{}
|
||||
case DeployKeyUserID:
|
||||
u = NewDeployKeyUser()
|
||||
u.ExtDoerData = &extDoerDeployKey{}
|
||||
default:
|
||||
return nil, ErrUserNotExist{UID: id}
|
||||
}
|
||||
return u, u.ExtDoerData.DecodeFromString(extDoerData)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ func TestSystemUser(t *testing.T) {
|
||||
assert.Equal(t, int64(-2), uid)
|
||||
assert.Equal(t, "gitea-actions", u.Name)
|
||||
assert.Equal(t, "gitea-actions", u.LowerName)
|
||||
assert.True(t, u.IsGiteaActions())
|
||||
|
||||
u = GetSystemUserByName("Gitea-actionS")
|
||||
require.NotNil(t, u)
|
||||
|
||||
@@ -24,20 +24,23 @@ const (
|
||||
|
||||
// HookOptions represents the options for the Hook calls
|
||||
type HookOptions struct {
|
||||
OldCommitIDs []string
|
||||
NewCommitIDs []string
|
||||
RefFullNames []git.RefName
|
||||
UserID int64
|
||||
UserName string
|
||||
IsWiki bool
|
||||
|
||||
OldCommitIDs []string
|
||||
NewCommitIDs []string
|
||||
RefFullNames []git.RefName
|
||||
|
||||
GitObjectDirectory string
|
||||
GitAlternativeObjectDirectories string
|
||||
GitQuarantinePath string
|
||||
GitPushOptions GitPushOptions
|
||||
PullRequestID int64
|
||||
PushTrigger repository.PushTrigger
|
||||
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
|
||||
IsWiki bool
|
||||
ActionsTaskID int64 // if the pusher is an Actions user, the task ID
|
||||
|
||||
PullRequestID int64
|
||||
PushTrigger repository.PushTrigger
|
||||
|
||||
UserID int64
|
||||
UserName string
|
||||
UserExtDoerData string
|
||||
}
|
||||
|
||||
// SSHLogOption ssh log options
|
||||
|
||||
@@ -33,16 +33,18 @@ func ServNoCommand(ctx context.Context, keyID int64) (*asymkey_model.PublicKey,
|
||||
|
||||
// ServCommandResults are the results of a call to the private route serv
|
||||
type ServCommandResults struct {
|
||||
IsWiki bool
|
||||
DeployKeyID int64
|
||||
KeyID int64 // public key
|
||||
KeyName string // this field is ambiguous, it can be the name of DeployKey, or the name of the PublicKey
|
||||
UserName string
|
||||
UserEmail string
|
||||
UserID int64
|
||||
OwnerName string
|
||||
RepoName string
|
||||
RepoID int64
|
||||
IsWiki bool
|
||||
|
||||
OwnerName string
|
||||
RepoName string
|
||||
RepoID int64
|
||||
|
||||
PublicKeyID int64
|
||||
|
||||
UserName string
|
||||
UserEmail string
|
||||
UserID int64
|
||||
UserExtDoerData string
|
||||
|
||||
RepoStoragePath string
|
||||
}
|
||||
|
||||
@@ -16,21 +16,23 @@ import (
|
||||
|
||||
// env keys for git hooks need
|
||||
const (
|
||||
EnvRepoName = "GITEA_REPO_NAME"
|
||||
EnvRepoUsername = "GITEA_REPO_USER_NAME"
|
||||
EnvRepoID = "GITEA_REPO_ID"
|
||||
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
|
||||
EnvPusherName = "GITEA_PUSHER_NAME"
|
||||
EnvPusherEmail = "GITEA_PUSHER_EMAIL"
|
||||
EnvPusherID = "GITEA_PUSHER_ID"
|
||||
EnvKeyID = "GITEA_KEY_ID" // public key ID
|
||||
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
|
||||
EnvPRID = "GITEA_PR_ID"
|
||||
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
|
||||
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
|
||||
EnvIsInternal = "GITEA_INTERNAL_PUSH"
|
||||
EnvAppURL = "GITEA_ROOT_URL"
|
||||
EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID"
|
||||
EnvRepoName = "GITEA_REPO_NAME"
|
||||
EnvRepoUsername = "GITEA_REPO_USER_NAME" // owner name
|
||||
EnvRepoID = "GITEA_REPO_ID"
|
||||
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
|
||||
|
||||
EnvKeyID = "GITEA_KEY_ID" // public key ID
|
||||
|
||||
EnvPusherName = "GITEA_PUSHER_NAME"
|
||||
EnvPusherEmail = "GITEA_PUSHER_EMAIL"
|
||||
EnvPusherID = "GITEA_PUSHER_ID"
|
||||
EnvPusherExtDoerData = "GITEA_PUSHER_EXT_DOER_DATA"
|
||||
|
||||
EnvPRID = "GITEA_PR_ID"
|
||||
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
|
||||
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
|
||||
EnvIsInternal = "GITEA_INTERNAL_PUSH"
|
||||
EnvAppURL = "GITEA_ROOT_URL"
|
||||
)
|
||||
|
||||
type PushTrigger string
|
||||
@@ -68,8 +70,8 @@ func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository,
|
||||
if !doer.KeepEmailPrivate {
|
||||
env = append(env, EnvPusherEmail+"="+doer.Email)
|
||||
}
|
||||
if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser {
|
||||
env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10))
|
||||
if doer.ExtDoerData != nil {
|
||||
env = append(env, EnvPusherExtDoerData+"="+doer.ExtDoerData.EncodeToString())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -146,8 +146,15 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co
|
||||
}
|
||||
}
|
||||
|
||||
// NewRequestContextForTest creates a new RequestContext for testing purposes
|
||||
// It doesn't add the context to the process manager, nor do cleanup
|
||||
func NewRequestContextForTest(parentCtx context.Context) RequestContext {
|
||||
return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}}
|
||||
type TestingT interface {
|
||||
Cleanup(func())
|
||||
Context() context.Context
|
||||
}
|
||||
|
||||
// NewRequestContextForTest creates a new RequestContext for testing purposes
|
||||
func NewRequestContextForTest(t TestingT) RequestContext {
|
||||
store := &requestDataStore{values: make(map[any]any)}
|
||||
ret := &requestContext{Context: t.Context(), RequestDataStore: store}
|
||||
t.Cleanup(store.cleanUp)
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -7,30 +7,33 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeployKey a deploy key
|
||||
type DeployKey struct {
|
||||
// ID is the unique identifier for the deploy key
|
||||
// ID is the unique identifier for the deploy-key
|
||||
ID int64 `json:"id"`
|
||||
// Type tells whether the key authenticates over SSH or with a token over HTTPS
|
||||
// enum: ssh,token
|
||||
KeyType string `json:"key_type"`
|
||||
// KeyID is the associated public key ID
|
||||
KeyID int64 `json:"key_id"`
|
||||
// Key contains the actual SSH key content
|
||||
Key string `json:"key"`
|
||||
// URL is the API URL for this deploy key
|
||||
// URL is the API URL for this deploy-key
|
||||
URL string `json:"url"`
|
||||
// Title is the human-readable name for the key
|
||||
Title string `json:"title"`
|
||||
// Fingerprint is the key's fingerprint
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
// Token is the plaintext token of an HTTPS key, only returned when it is created
|
||||
Token string `json:"token,omitempty"`
|
||||
// swagger:strfmt date-time
|
||||
// Created is the time when the deploy key was added
|
||||
// Created is the time when the deploy-key was added
|
||||
Created time.Time `json:"created_at"`
|
||||
// ReadOnly indicates if the key has read-only access
|
||||
ReadOnly bool `json:"read_only"`
|
||||
// Repository is the repository this deploy key belongs to
|
||||
// Repository is the repository this deploy-key belongs to
|
||||
Repository *Repository `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
// CreateKeyOption options when creating a key
|
||||
type CreateKeyOption struct {
|
||||
// Title of the key to add
|
||||
//
|
||||
@@ -43,7 +46,15 @@ type CreateKeyOption struct {
|
||||
// unique: true
|
||||
Key string `json:"key" binding:"Required"`
|
||||
// Describe if the key has only read access or read/write
|
||||
//
|
||||
// required: false
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
type CreateDeployKeyTokenOption struct {
|
||||
// Title of the token to add
|
||||
//
|
||||
// required: true
|
||||
// unique: true
|
||||
Title string `json:"title" binding:"Required;MaxSize(50)"`
|
||||
// Describe if the token has only read access or read/write
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestRenderTimelineEventComment(t *testing.T) {
|
||||
ctx := reqctx.NewRequestContextForTest(t.Context())
|
||||
ctx := reqctx.NewRequestContextForTest(t)
|
||||
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
|
||||
ut := &RenderUtils{ctx: ctx}
|
||||
var createdStr template.HTML = "(created-at)"
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func newTestRenderUtils(t *testing.T) *RenderUtils {
|
||||
ctx := reqctx.NewRequestContextForTest(t.Context())
|
||||
ctx := reqctx.NewRequestContextForTest(t)
|
||||
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
|
||||
return NewRenderUtils(ctx)
|
||||
}
|
||||
|
||||
@@ -2387,19 +2387,22 @@
|
||||
"repo.settings.packagist_api_token": "API token",
|
||||
"repo.settings.packagist_package_url": "Packagist package URL",
|
||||
"repo.settings.deploy_keys": "Deploy Keys",
|
||||
"repo.settings.add_deploy_key": "Add Deploy Key",
|
||||
"repo.settings.deploy_key_desc": "Deploy keys have read-only pull access to the repository.",
|
||||
"repo.settings.is_writable": "Enable Write Access",
|
||||
"repo.settings.is_writable_info": "Allow this deploy key to <strong>push</strong> to the repository.",
|
||||
"repo.settings.no_deploy_keys": "There are no deploy keys yet.",
|
||||
"repo.settings.title": "Title",
|
||||
"repo.settings.deploy_key_content": "Content",
|
||||
"repo.settings.add_deploy_key_ssh": "Add SSH key",
|
||||
"repo.settings.deploy_key_ssh_desc": "Add an SSH public key as deploy key, then use its private key to access the repository.",
|
||||
"repo.settings.deploy_key_token_desc": "An HTTP token is used as the password of a Git request over HTTPS for this repository.",
|
||||
"repo.settings.generate_deploy_token": "Generate HTTP Token",
|
||||
"repo.settings.generate_deploy_token_success": "HTTP token %s generated. Copy it now, it is not shown again.",
|
||||
"repo.settings.regenerate_deploy_token_desc": "Regenerating an HTTP token will revoke the current one. Continue?",
|
||||
"repo.settings.regenerate_deploy_token_success": "HTTP token %s regenerated. Copy it now, it is not shown again.",
|
||||
"repo.settings.add_key_success": "Deploy key %s has been added.",
|
||||
"repo.settings.key_been_used": "A deploy key with identical content is already in use.",
|
||||
"repo.settings.key_name_used": "A deploy key with the same name already exists.",
|
||||
"repo.settings.add_key_success": "The deploy key \"%s\" has been added.",
|
||||
"repo.settings.deploy_key_deletion": "Remove Deploy Key",
|
||||
"repo.settings.deploy_key_deletion_desc": "Removing a deploy key will revoke its access to this repository. Continue?",
|
||||
"repo.settings.deploy_key_deletion_success": "The deploy key has been removed.",
|
||||
"repo.settings.deploy_key_deletion_success": "Deploy key %s has been removed.",
|
||||
"repo.settings.branches": "Branches",
|
||||
"repo.settings.protected_branch": "Branch Protection",
|
||||
"repo.settings.protected_branch.save_rule": "Save Rule",
|
||||
|
||||
@@ -189,13 +189,7 @@ func repoAssignment() func(ctx *context.APIContext) {
|
||||
repo.Owner = owner
|
||||
ctx.Repo.Repository = repo
|
||||
|
||||
if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
|
||||
ctx.Repo.Permission, err = access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
{
|
||||
needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -228,7 +222,7 @@ func doerNeedTwoFactorAuth(ctx gocontext.Context, doer *user_model.User) (bool,
|
||||
if !setting.TwoFactorAuthEnforced {
|
||||
return false, nil
|
||||
}
|
||||
if doer == nil {
|
||||
if doer == nil || !doer.IsIndividual() { // system doers like Actions tasks or deploy-keys can never enroll 2FA
|
||||
return false, nil
|
||||
}
|
||||
has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, doer.ID)
|
||||
@@ -1448,6 +1442,7 @@ func Routes() *web.Router {
|
||||
m.Group("/keys", func() {
|
||||
m.Combo("").Get(repo.ListDeployKeys).
|
||||
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
|
||||
m.Post("/tokens", bind(api.CreateDeployKeyTokenOption{}), repo.CreateDeployToken)
|
||||
m.Combo("/{id}").Get(repo.GetDeployKey).
|
||||
Delete(repo.DeleteDeployKey)
|
||||
}, reqToken(), reqAdmin())
|
||||
|
||||
25
routers/api/v1/api_test.go
Normal file
25
routers/api/v1/api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// appendPrivateInformation appends the owner and key type information to api.PublicKey
|
||||
func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *asymkey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
|
||||
func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *deploykey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
|
||||
apiKey.ReadOnly = key.Mode == perm.AccessModeRead
|
||||
if repository.ID == key.RepoID {
|
||||
apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode})
|
||||
@@ -78,14 +79,14 @@ func ListDeployKeys(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opts := asymkey_model.ListDeployKeysOptions{
|
||||
opts := deploykey_model.ListDeployKeysOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
KeyID: ctx.FormInt64("key_id"),
|
||||
Fingerprint: ctx.FormString("fingerprint"),
|
||||
}
|
||||
|
||||
keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts)
|
||||
keys, count, err := db.FindAndCount[deploykey_model.DeployKey](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -133,7 +134,7 @@ func GetDeployKey(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
key, err := deploykey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
@@ -160,13 +161,13 @@ func HandleCheckKeyStringError(ctx *context.APIContext, err error) {
|
||||
// HandleAddKeyError handle add key error
|
||||
func HandleAddKeyError(ctx *context.APIContext, err error) {
|
||||
switch {
|
||||
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "This key has already been added to this repository")
|
||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "Key content has been used as non-deploy key")
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "Key title has been used")
|
||||
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
case deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "A key with the same name already exists")
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -213,7 +214,7 @@ func CreateDeployKey(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
|
||||
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
if err != nil {
|
||||
HandleAddKeyError(ctx, err)
|
||||
return
|
||||
@@ -221,6 +222,49 @@ func CreateDeployKey(ctx *context.APIContext) {
|
||||
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
|
||||
}
|
||||
|
||||
// CreateDeployToken create a deploy token for a repository
|
||||
func CreateDeployToken(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/keys/tokens repository repoCreateDeployToken
|
||||
// ---
|
||||
// summary: Add a deploy token to a repository, it authenticates git over HTTPS
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateDeployKeyTokenOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/DeployKey"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm[*api.CreateDeployKeyTokenOption](ctx)
|
||||
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
|
||||
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
|
||||
if err != nil {
|
||||
HandleAddKeyError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
|
||||
}
|
||||
|
||||
// DeleteDeployKey delete deploy key for a repository
|
||||
func DeleteDeployKey(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey
|
||||
@@ -251,7 +295,8 @@ func DeleteDeployKey(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil {
|
||||
// a key that is already gone still leaves the caller with the state it asked for
|
||||
if _, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
if asymkey_model.IsErrKeyAccessDenied(err) {
|
||||
ctx.APIError(http.StatusForbidden, "You do not have access to this key")
|
||||
} else {
|
||||
|
||||
@@ -53,6 +53,9 @@ type swaggerParameterBodies struct {
|
||||
// in:body
|
||||
CreateKeyOption api.CreateKeyOption
|
||||
|
||||
// in:body
|
||||
CreateDeployKeyTokenOption api.CreateDeployKeyTokenOption
|
||||
|
||||
// in:body
|
||||
RenameUserOption api.RenameUserOption
|
||||
|
||||
|
||||
@@ -58,16 +58,13 @@ func Search(ctx *context.APIContext) {
|
||||
uid := ctx.FormInt64("uid")
|
||||
var users []*user_model.User
|
||||
var maxResults int64
|
||||
var err error
|
||||
|
||||
switch uid {
|
||||
case user_model.GhostUserID:
|
||||
maxResults = 1
|
||||
users = []*user_model.User{user_model.NewGhostUser()}
|
||||
case user_model.ActionsUserID:
|
||||
maxResults = 1
|
||||
users = []*user_model.User{user_model.NewActionsUser()}
|
||||
default:
|
||||
if uid < 0 {
|
||||
_, sysUser, _ := user_model.GetPossibleUserByID(ctx, uid)
|
||||
if sysUser != nil && sysUser.ID == uid {
|
||||
maxResults = 1
|
||||
users = []*user_model.User{sysUser}
|
||||
}
|
||||
} else {
|
||||
opts := user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
@@ -77,6 +74,7 @@ func Search(ctx *context.APIContext) {
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
opts.ApplyPublicOnly(ctx.PublicOnly)
|
||||
var err error
|
||||
users, maxResults, err = user_model.SearchUsers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]any{
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
|
||||
t.Run("HTML", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}}
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t))
|
||||
renderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
|
||||
respContent := w.Body.String()
|
||||
assert.Contains(t, respContent, `class="page-content status-page-500"`)
|
||||
@@ -36,7 +36,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
|
||||
t.Run("Plain", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := &http.Request{URL: &url.URL{}}
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t))
|
||||
renderServiceUnavailable(w, req)
|
||||
assert.Equal(t, "Service Unavailable", w.Body.String())
|
||||
})
|
||||
|
||||
@@ -4,18 +4,13 @@
|
||||
package private
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/cachegroup"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/private"
|
||||
@@ -103,15 +98,11 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
|
||||
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ownerName := ctx.PathParam("owner")
|
||||
repoName := ctx.PathParam("repo")
|
||||
repo := loadRepository(ctx, ownerName, repoName)
|
||||
if ctx.Written() {
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
// now, repo can't be nil
|
||||
|
||||
repo := ctx.Repo.Repository
|
||||
// first, collect updates and sync branches
|
||||
updates := hookPostReceiveCollectPushUpdates(opts, repo)
|
||||
if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) {
|
||||
@@ -144,17 +135,7 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
||||
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
|
||||
// Handle Push Options
|
||||
if isPrivate.Has() || isTemplate.Has() {
|
||||
pusher, err := loadContextCacheUser(ctx, opts.UserID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to load pusher user: %v", err)
|
||||
return false
|
||||
}
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repo, pusher)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to load doer repo permission: %v", err)
|
||||
return false
|
||||
}
|
||||
if !perm.IsOwner() && !perm.IsAdmin() {
|
||||
if !ctx.Repo.Permission.IsAdmin() {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied")
|
||||
return false
|
||||
}
|
||||
@@ -171,13 +152,13 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
||||
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||
repo.IsPrivate = isPrivate.Value()
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("failed to update repo is_private: %v", err)
|
||||
}
|
||||
}
|
||||
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
|
||||
repo.IsTemplate = isTemplate.Value()
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
|
||||
log.Error("failed to update repo is_template: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -244,10 +225,6 @@ func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *
|
||||
ctx.JSON(http.StatusOK, private.HookPostReceiveResult{Results: results})
|
||||
}
|
||||
|
||||
func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
|
||||
return cache.GetWithContextCache(ctx, cachegroup.User, id, user_model.GetUserByID)
|
||||
}
|
||||
|
||||
// hookPostReceiveHandlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
|
||||
func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool {
|
||||
if len(updates) == 0 {
|
||||
@@ -261,15 +238,9 @@ func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext,
|
||||
return false
|
||||
}
|
||||
|
||||
pusher, err := loadContextCacheUser(ctx, opts.UserID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to load pusher user %d: %v", opts.UserID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// FIXME: Maybe we need a `PullRequestStatusMerged` status for PRs that are merged, currently we use the previous status
|
||||
// here to keep it as before, that maybe PullRequestStatusMergeable
|
||||
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), pusher, pr.Status)
|
||||
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), ctx.Doer, pr.Status)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err)
|
||||
return false
|
||||
|
||||
@@ -25,13 +25,14 @@ func TestHandlePullRequestMerging(t *testing.T) {
|
||||
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
err = pull_model.ScheduleAutoMerge(t.Context(), user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
|
||||
|
||||
ctx, resp := contexttest.MockPrivateContext(t, "/")
|
||||
ctx.Doer = user2
|
||||
hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{
|
||||
PullRequestID: pr.ID,
|
||||
UserID: 2,
|
||||
|
||||
@@ -8,11 +8,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -27,29 +24,18 @@ import (
|
||||
|
||||
type preReceiveContext struct {
|
||||
*gitea_context.PrivateContext
|
||||
|
||||
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
|
||||
userPerm access_model.Permission
|
||||
deployKeyAccessMode perm_model.AccessMode
|
||||
|
||||
canCreatePullRequest bool
|
||||
checkedCanCreatePullRequest bool
|
||||
|
||||
protectedTags []*git_model.ProtectedTag
|
||||
gotProtectedTags bool
|
||||
|
||||
env []string
|
||||
|
||||
env []string
|
||||
opts *private.HookOptions
|
||||
|
||||
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||
canWriteCodeUnitCached *bool
|
||||
canCreatePullRequest *bool
|
||||
protectedTags []*git_model.ProtectedTag
|
||||
}
|
||||
|
||||
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||
if ctx.canWriteCodeUnitCached == nil {
|
||||
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
ctx.canWriteCodeUnitCached = &canWrite
|
||||
ctx.canWriteCodeUnitCached = new(ctx.Repo.Permission.CanWrite(unit.TypeCode))
|
||||
}
|
||||
return *ctx.canWriteCodeUnitCached
|
||||
}
|
||||
@@ -63,7 +49,7 @@ func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
|
||||
if !refFullName.IsBranch() {
|
||||
return false
|
||||
}
|
||||
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, refFullName.BranchName(), ctx.Doer)
|
||||
}
|
||||
|
||||
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||
@@ -80,11 +66,10 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||
|
||||
// CanCreatePullRequest returns true if pusher can create pull requests
|
||||
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
|
||||
if !ctx.checkedCanCreatePullRequest {
|
||||
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
|
||||
ctx.checkedCanCreatePullRequest = true
|
||||
if ctx.canCreatePullRequest == nil {
|
||||
ctx.canCreatePullRequest = new(ctx.Repo.Permission.CanRead(unit.TypePullRequests))
|
||||
}
|
||||
return ctx.canCreatePullRequest
|
||||
return *ctx.canCreatePullRequest
|
||||
}
|
||||
|
||||
// AssertCreatePullRequest returns true if can create pull requests
|
||||
@@ -102,6 +87,9 @@ func (ctx *preReceiveContext) AssertCreatePullRequest() bool {
|
||||
// HookPreReceive checks whether a individual commit is acceptable
|
||||
func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
opts := web.GetForm[*private.HookOptions](ctx)
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
|
||||
ourCtx := &preReceiveContext{
|
||||
PrivateContext: ctx,
|
||||
@@ -109,10 +97,6 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
if !ourCtx.loadPusherAndPermission() {
|
||||
return // if error occurs, loadPusherAndPermission had written the error response
|
||||
}
|
||||
|
||||
// Iterate across the provided old commit IDs
|
||||
for i := range opts.OldCommitIDs {
|
||||
oldCommitID := opts.OldCommitIDs[i]
|
||||
@@ -236,7 +220,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
|
||||
// 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
|
||||
var canPush bool
|
||||
if ctx.opts.DeployKeyID != 0 {
|
||||
if ctx.opts.UserID == user_model.DeployKeyUserID {
|
||||
// This flag is only ever true if protectBranch.CanForcePush is true
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
|
||||
@@ -245,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
} else {
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
|
||||
} else {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +280,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
|
||||
// Now check if the user is allowed to merge PRs for this repository
|
||||
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
|
||||
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
|
||||
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.Repo.Permission, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Error calculating if allowed to merge: %v", err)
|
||||
return
|
||||
@@ -308,7 +292,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
|
||||
// If we can bypass branch protection we can ignore status checks, reviews and protected files
|
||||
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
|
||||
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.Doer, ctx.Repo.Permission.IsAdmin()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -337,14 +321,14 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||
|
||||
tagName := refFullName.TagName()
|
||||
|
||||
if !ctx.gotProtectedTags {
|
||||
if ctx.protectedTags == nil {
|
||||
var err error
|
||||
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get protected tags: %v", err)
|
||||
return
|
||||
}
|
||||
ctx.gotProtectedTags = true
|
||||
ctx.protectedTags = util.SliceNilAsEmpty(ctx.protectedTags)
|
||||
}
|
||||
|
||||
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
|
||||
@@ -399,45 +383,3 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
|
||||
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||
if ctx.opts.UserID == user_model.ActionsUserID {
|
||||
taskID := ctx.opts.ActionsTaskID
|
||||
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
|
||||
if taskID == 0 {
|
||||
ctx.PrivateUserErrorf(http.StatusInternalServerError, "ActionsUser with task ID 0")
|
||||
return false
|
||||
}
|
||||
|
||||
userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err)
|
||||
return false
|
||||
}
|
||||
ctx.userPerm = userPerm
|
||||
} else {
|
||||
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
|
||||
return false
|
||||
}
|
||||
ctx.user = user
|
||||
userPerm, err := access_model.GetDoerRepoPermission(ctx, ctx.Repo.Repository, user)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err)
|
||||
return false
|
||||
}
|
||||
ctx.userPerm = userPerm
|
||||
}
|
||||
|
||||
if ctx.opts.DeployKeyID != 0 {
|
||||
deployKey, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.opts.DeployKeyID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err)
|
||||
return false
|
||||
}
|
||||
ctx.deployKeyAccessMode = deployKey.Mode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
|
||||
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
// together with a protected branch or a tag to escalate into full repository write.
|
||||
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
@@ -45,16 +44,12 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||
|
||||
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||
maintainer := baseRepo.Owner
|
||||
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{
|
||||
PrivateContext: mockCtx,
|
||||
user: maintainer,
|
||||
userPerm: headPerm,
|
||||
}
|
||||
ctx := &preReceiveContext{PrivateContext: mockCtx}
|
||||
ctx.SetPathParam("owner", headRepo.OwnerName)
|
||||
ctx.SetPathParam("repo", headRepo.Name)
|
||||
RepoAssignment(ctx.PrivateContext)
|
||||
loadContextDoerPermission(ctx.PrivateContext, baseRepo.OwnerID, "")
|
||||
|
||||
// The granted branch must be writable...
|
||||
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||
|
||||
@@ -23,8 +23,17 @@ func HookProcReceive(ctx *gitea_context.PrivateContext) {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
|
||||
results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, opts)
|
||||
results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, &agit.ProcReceiveOptions{
|
||||
OldCommitIDs: opts.OldCommitIDs,
|
||||
NewCommitIDs: opts.NewCommitIDs,
|
||||
RefFullNames: opts.RefFullNames,
|
||||
GitPushOptions: opts.GitPushOptions,
|
||||
Doer: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, issues_model.ErrMustCollaborator) {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "You must be a collaborator to create pull request.")
|
||||
|
||||
@@ -80,7 +80,7 @@ func Routes() *web.Router {
|
||||
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
|
||||
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
|
||||
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
|
||||
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), bind(private.HookOptions{}), HookPostReceive)
|
||||
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookPostReceive)
|
||||
r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
|
||||
r.Get("/serv/none/{keyid}", ServNoCommand)
|
||||
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package private
|
||||
|
||||
import (
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
gitea_context "gitea.dev/services/context"
|
||||
)
|
||||
@@ -27,10 +29,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) {
|
||||
ctx.PrivateInternalErrorf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
|
||||
return
|
||||
}
|
||||
ctx.Repo = &gitea_context.Repository{
|
||||
Repository: repo,
|
||||
GitRepo: gitRepo,
|
||||
}
|
||||
ctx.Repo = &gitea_context.Repository{Repository: repo, GitRepo: gitRepo}
|
||||
}
|
||||
|
||||
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository {
|
||||
@@ -44,3 +43,18 @@ func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName strin
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func loadContextDoerPermission(ctx *gitea_context.PrivateContext, userID int64, extDoerData string) bool {
|
||||
doer, err := user.GetDoerUser(ctx, userID, extDoerData)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Failed to get user: %d, error: %v", userID, err)
|
||||
return false
|
||||
}
|
||||
ctx.Doer = doer
|
||||
ctx.Repo.Permission, err = access.GetDoerRepoPermission(ctx, ctx.Repo.Repository, doer)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Failed to get permission for user: %d, error: %v", userID, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/modules/timeutil"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
@@ -20,17 +20,16 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
|
||||
return
|
||||
}
|
||||
|
||||
deployKey, err := asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
|
||||
deployKey, err := deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrDeployKeyNotExist(err) {
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.PlainText(http.StatusOK, "success")
|
||||
return
|
||||
}
|
||||
ctx.PrivateInternalErrorf("%v", err)
|
||||
return
|
||||
}
|
||||
deployKey.UpdatedUnix = timeutil.TimeStampNow()
|
||||
if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil {
|
||||
if err = deploykey_model.UpdateDeployKeyLastUsed(ctx, deployKey.ID); err != nil {
|
||||
ctx.PrivateInternalErrorf("%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -73,9 +74,9 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
|
||||
// Set the basic parts of the results to return
|
||||
results := private.ServCommandResults{
|
||||
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
|
||||
RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
|
||||
KeyID: keyID,
|
||||
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
|
||||
RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
|
||||
PublicKeyID: keyID,
|
||||
}
|
||||
repoLogName := reqOwnerName + "/" + reqRepoName
|
||||
|
||||
@@ -184,40 +185,25 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
ctx.PrivateInternalErrorf("Unable to get key: %d, error: %v", keyID, err)
|
||||
return
|
||||
}
|
||||
results.KeyName = key.Name
|
||||
results.KeyID = key.ID
|
||||
results.UserID = key.OwnerID
|
||||
results.PublicKeyID = key.ID
|
||||
|
||||
// Deploy Keys have ownerID set to 0 therefore we can't use the owner
|
||||
// So now we need to check if the key is a deploy key
|
||||
// We'll keep hold of the deploy key here for permissions checking
|
||||
var deployKey *asymkey_model.DeployKey
|
||||
var deployKey *deploykey_model.DeployKey
|
||||
var user *user_model.User
|
||||
if key.Type == asymkey_model.KeyTypeDeploy {
|
||||
if repo == nil {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName)
|
||||
return
|
||||
}
|
||||
deployKey, err = asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
|
||||
deployKey, err = deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy-key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
ctx.PrivateInternalErrorf("Unable to get deploy for public (deploy) key %d for %s, error: %v", key.ID, repoLogName, err)
|
||||
return
|
||||
}
|
||||
results.DeployKeyID = deployKey.ID
|
||||
results.KeyName = deployKey.Name
|
||||
|
||||
// FIXME: Deploy keys aren't really the owner of the repo pushing changes
|
||||
// however we don't have good way of representing deploy keys in hook.go
|
||||
// so for now use the owner of the repository
|
||||
results.UserName = results.OwnerName
|
||||
results.UserID = repo.OwnerID
|
||||
if !repo.Owner.KeepEmailPrivate {
|
||||
results.UserEmail = repo.Owner.Email
|
||||
}
|
||||
user = user_model.NewDeployKeyUserWithKeyID(deployKey.ID)
|
||||
} else {
|
||||
// Get the user represented by the Key
|
||||
user, err = user_model.GetUserByID(ctx, key.OwnerID)
|
||||
@@ -229,16 +215,19 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
ctx.PrivateInternalErrorf("Unable to get key owner %d for public key %d:%s, error: %v", key.OwnerID, key.ID, key.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsActive || user.ProhibitLogin {
|
||||
ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
results.UserName = user.Name
|
||||
if !user.KeepEmailPrivate {
|
||||
results.UserEmail = user.Email
|
||||
}
|
||||
results.UserID = user.ID
|
||||
results.UserName = user.Name
|
||||
if !user.KeepEmailPrivate {
|
||||
results.UserEmail = user.Email
|
||||
}
|
||||
if user.ExtDoerData != nil {
|
||||
results.UserExtDoerData = user.ExtDoerData.EncodeToString()
|
||||
}
|
||||
|
||||
// Don't allow pushing if the repo is archived
|
||||
@@ -252,37 +241,29 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
(mode > perm.AccessModeRead ||
|
||||
repo.IsPrivate ||
|
||||
owner.Visibility.IsPrivate() ||
|
||||
(user != nil && user.IsRestricted) || // user will be nil if the key is a deploy key
|
||||
user.IsRestricted ||
|
||||
setting.Service.RequireSignInViewStrict) {
|
||||
if key.Type == asymkey_model.KeyTypeDeploy {
|
||||
if deployKey == nil || deployKey.Mode < mode {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
|
||||
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
|
||||
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
|
||||
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
|
||||
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
|
||||
mode = perm.AccessModeRead
|
||||
}
|
||||
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
|
||||
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
|
||||
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
|
||||
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
|
||||
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
|
||||
mode = perm.AccessModeRead
|
||||
}
|
||||
|
||||
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
|
||||
return
|
||||
}
|
||||
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
|
||||
return
|
||||
}
|
||||
|
||||
userMode := userPerm.UnitAccessMode(unitType)
|
||||
if userMode < mode {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User %d with key %d:%s has no %q permission for %s", key.OwnerID, key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
userMode := userPerm.UnitAccessMode(unitType)
|
||||
if userMode < mode {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User key %d:%s has no %q permission for %s", key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// We already know we aren't using a deploy key
|
||||
if repo == nil {
|
||||
if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
|
||||
ctx.PrivateUserErrorf(http.StatusForbidden, "Push to create is not enabled for organizations.")
|
||||
|
||||
@@ -212,7 +212,7 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil)
|
||||
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(t, resp, req), nil, nil)
|
||||
ctx.Repo.Repository = &repo_model.Repository{
|
||||
OwnerName: "user1",
|
||||
Name: "repo1",
|
||||
|
||||
@@ -163,7 +163,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && !ctx.Doer.IsGiteaActions() {
|
||||
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && ctx.Doer.IsIndividual() {
|
||||
_, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
|
||||
if err == nil {
|
||||
// TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
|
||||
@@ -252,7 +252,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
|
||||
var environ []string
|
||||
if !isPull {
|
||||
// if not "pull", then must be "push", and doer must exist
|
||||
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -17,23 +19,20 @@ import (
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
// DeployKeys render the deploy-keys list of a repository page
|
||||
func DeployKeys(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets")
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
|
||||
ctx.Data["PageIsSettingsKeys"] = true
|
||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
||||
|
||||
keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
|
||||
keys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
|
||||
if err != nil {
|
||||
ctx.ServerError("ListDeployKeys", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["RepoDeployKeys"] = keys
|
||||
|
||||
ctx.HTML(http.StatusOK, tplDeployKeys)
|
||||
}
|
||||
|
||||
// DeployKeysPost response for adding a deploy-key of a repository
|
||||
func DeployKeysPost(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.AddKeyForm](ctx)
|
||||
if form == nil {
|
||||
@@ -54,16 +53,14 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
|
||||
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
if err != nil {
|
||||
switch {
|
||||
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content")
|
||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content")
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err), deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
default:
|
||||
ctx.ServerError("AddDeployKey", err)
|
||||
@@ -75,12 +72,50 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
// DeleteDeployKey response for deleting a deploy-key
|
||||
func DeleteDeployKey(ctx *context.Context) {
|
||||
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil {
|
||||
key, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id"))
|
||||
if err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) { // a key that is already gone leaves the caller with the state it asked for
|
||||
ctx.ServerError("DeleteDeployKey", err)
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
|
||||
return
|
||||
}
|
||||
if key != nil {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success", key.Name))
|
||||
}
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
func DeployKeyGenerateToken(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.AddDeployTokenForm](ctx)
|
||||
if form == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
|
||||
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNameAlreadyUsed(err) {
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
} else {
|
||||
ctx.ServerError("AddDeployToken", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.generate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
func DeployKeyRegenerateToken(ctx *context.Context) {
|
||||
key, err := deploykey_model.RegenerateDeployKeyToken(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id"))
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.JSONErrorNotFound()
|
||||
} else {
|
||||
ctx.ServerError("RegenerateDeployToken", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.regenerate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
@@ -38,7 +38,7 @@ func TestAddDeployKey(t *testing.T) {
|
||||
contexttest.LoadRepo(t, ctx, 2)
|
||||
DeployKeysPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
|
||||
})
|
||||
t.Run("ReadWrite", func(t *testing.T) {
|
||||
const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n"
|
||||
@@ -47,7 +47,7 @@ func TestAddDeployKey(t *testing.T) {
|
||||
contexttest.LoadRepo(t, ctx, 2)
|
||||
DeployKeysPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
|
||||
type AuthMiddleware struct {
|
||||
AllowOAuth2 types.PreMiddlewareProvider
|
||||
AllowBasic types.PreMiddlewareProvider
|
||||
AllowDeployToken types.PreMiddlewareProvider
|
||||
MiddlewareHandler func(*context.Context)
|
||||
}
|
||||
|
||||
func newWebAuthMiddleware() *AuthMiddleware {
|
||||
type keyAllowOAuth2 struct{}
|
||||
type keyAllowBasic struct{}
|
||||
type keyAllowDeployToken struct{}
|
||||
webAuth := &AuthMiddleware{}
|
||||
|
||||
middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider {
|
||||
@@ -114,11 +116,13 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
|
||||
webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true)
|
||||
webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true)
|
||||
webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true)
|
||||
|
||||
enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext())
|
||||
webAuth.MiddlewareHandler = func(ctx *context.Context) {
|
||||
allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true
|
||||
allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true
|
||||
allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true
|
||||
|
||||
group := auth_service.NewGroup()
|
||||
|
||||
@@ -127,13 +131,16 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
if allowOAuth2 {
|
||||
group.Add(&auth_service.OAuth2{})
|
||||
}
|
||||
if allowDeployToken {
|
||||
group.Add(&auth_service.DeployToken{}) // before Basic, which would try the token as a password
|
||||
}
|
||||
if allowBasic {
|
||||
group.Add(&auth_service.Basic{})
|
||||
}
|
||||
|
||||
// Sessionless means the route's auth can be done without web ui, then it doesn't need to create a session
|
||||
// For example: accessing git via http, access rss feeds, downloading attachments, etc
|
||||
isSessionless := allowOAuth2 || allowBasic
|
||||
isSessionless := allowOAuth2 || allowBasic || allowDeployToken
|
||||
|
||||
if setting.Service.EnableReverseProxyAuth {
|
||||
// reverse-proxy should before Session, otherwise the header will be ignored if user has login
|
||||
@@ -1223,6 +1230,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/keys", func() {
|
||||
m.Combo("").Get(repo_setting.DeployKeys).
|
||||
Post(repo_setting.DeployKeysPost)
|
||||
m.Post("/generate-token", repo_setting.DeployKeyGenerateToken)
|
||||
m.Post("/regenerate-token", repo_setting.DeployKeyRegenerateToken)
|
||||
m.Post("/delete", repo_setting.DeleteDeployKey)
|
||||
})
|
||||
|
||||
@@ -1743,12 +1752,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
|
||||
// git lfs uses its own jwt key, and it handles the token & auth by itself, it conflicts with the general "OAuth2" auth method
|
||||
// pattern: "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters
|
||||
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, repo.CorsHandler(), optSignInFromAnyOrigin)
|
||||
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin)
|
||||
|
||||
// Some users want to use "web-based git client" to access Gitea's repositories,
|
||||
// so the CORS handler and OPTIONS method are used.
|
||||
// pattern: "/{username}/{reponame}/{git-paths}": git http support
|
||||
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
|
||||
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
|
||||
|
||||
m.Group("/notifications", func() {
|
||||
m.Get("", user.Notifications)
|
||||
|
||||
@@ -121,7 +121,7 @@ func (input *notifyInput) Notify(ctx context.Context) {
|
||||
|
||||
func notify(ctx context.Context, input *notifyInput) error {
|
||||
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
|
||||
if input.Doer.IsGiteaActions() {
|
||||
if input.Doer.ID == user_model.ActionsUserID {
|
||||
// avoiding triggering cyclically, for example:
|
||||
// a comment of an issue will trigger the runner to add a new comment as reply,
|
||||
// and the new comment will trigger the runner again.
|
||||
|
||||
@@ -60,8 +60,18 @@ func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string)
|
||||
return "", "", util.NewNotExistErrorf("base branch does not exist")
|
||||
}
|
||||
|
||||
type ProcReceiveOptions struct {
|
||||
OldCommitIDs []string
|
||||
NewCommitIDs []string
|
||||
RefFullNames []git.RefName
|
||||
|
||||
GitPushOptions private.GitPushOptions
|
||||
|
||||
Doer *user_model.User
|
||||
}
|
||||
|
||||
// ProcReceive handle proc receive work
|
||||
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
|
||||
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *ProcReceiveOptions) ([]private.HookProcReceiveRefResult, error) {
|
||||
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
|
||||
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
|
||||
topicBranch := opts.GitPushOptions["topic"]
|
||||
@@ -72,12 +82,9 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
|
||||
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
|
||||
|
||||
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
||||
userName := strings.ToLower(opts.UserName)
|
||||
|
||||
pusher, err := user_model.GetUserByID(ctx, opts.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user. Error: %w", err)
|
||||
}
|
||||
pusher := opts.Doer
|
||||
userName := strings.ToLower(pusher.Name)
|
||||
|
||||
for i := range opts.OldCommitIDs {
|
||||
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
|
||||
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
)
|
||||
|
||||
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
|
||||
func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
|
||||
deployKeys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: repoID})
|
||||
deployKeys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: repoID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listDeployKeys: %w", err)
|
||||
}
|
||||
@@ -28,13 +29,17 @@ func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
|
||||
}
|
||||
|
||||
// deleteDeployKeyFromDB delete deploy keys from database
|
||||
func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) error {
|
||||
if _, err := db.DeleteByID[asymkey_model.DeployKey](ctx, key.ID); err != nil {
|
||||
func deleteDeployKeyFromDB(ctx context.Context, key *deploykey_model.DeployKey) error {
|
||||
if _, err := db.DeleteByID[deploykey_model.DeployKey](ctx, key.ID); err != nil {
|
||||
return fmt.Errorf("delete deploy key [%d]: %w", key.ID, err)
|
||||
}
|
||||
|
||||
if key.KeyType == deploykey_model.KeyTypeToken { // a token has no public key to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is the last reference to same key content.
|
||||
has, err := asymkey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
|
||||
has, err := deploykey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
@@ -46,21 +51,22 @@ func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed.
|
||||
// Permissions check should be done outside.
|
||||
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) error {
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
key, err := asymkey_model.GetDeployKeyByID(ctx, repo.ID, id)
|
||||
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed,
|
||||
// and returns the key it deleted. Permissions check should be done outside.
|
||||
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) (*deploykey_model.DeployKey, error) {
|
||||
deleted, err := db.WithTx2(ctx, func(ctx context.Context) (*deploykey_model.DeployKey, error) {
|
||||
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, id)
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrDeployKeyNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("GetDeployKeyByID: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return deleteDeployKeyFromDB(ctx, key)
|
||||
}); err != nil {
|
||||
return err
|
||||
return key, deleteDeployKeyFromDB(ctx, key)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted.KeyType == deploykey_model.KeyTypeToken {
|
||||
return deleted, nil // a token never appears in the authorized_keys file
|
||||
}
|
||||
|
||||
return RewriteAllPublicKeys(ctx)
|
||||
return deleted, RewriteAllPublicKeys(ctx)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
AccessTokenMethodName = "access_token"
|
||||
OAuth2TokenMethodName = "oauth2_token"
|
||||
ActionTokenMethodName = "action_token"
|
||||
DeployTokenMethodName = "deploy_token"
|
||||
)
|
||||
|
||||
// Basic implements the Auth interface and authenticates requests (API requests
|
||||
@@ -41,7 +42,7 @@ func (b *Basic) Name() string {
|
||||
return BasicMethodName
|
||||
}
|
||||
|
||||
func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
|
||||
func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return ret
|
||||
@@ -53,7 +54,7 @@ func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname,
|
||||
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
||||
|
||||
// Check if username or password is a token
|
||||
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
|
||||
isUsernameToken := passwd == "" || passwd == "x-oauth-basic"
|
||||
// Assume username is token
|
||||
authToken := uname
|
||||
if !isUsernameToken {
|
||||
@@ -122,7 +123,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
|
||||
// name/token on successful validation.
|
||||
// Returns nil if header is empty or validation fails.
|
||||
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
parseBasicRet := b.parseAuthBasic(req)
|
||||
parseBasicRet := parseAuthBasic(req)
|
||||
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
|
||||
if authToken == "" && uname == "" {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
|
||||
47
services/auth/deploy_token.go
Normal file
47
services/auth/deploy_token.go
Normal 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
|
||||
}
|
||||
@@ -225,11 +225,11 @@ func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
|
||||
return b
|
||||
}
|
||||
|
||||
func NewBaseContextForTest(resp http.ResponseWriter, req *http.Request) *Base {
|
||||
func NewBaseContextForTest(t reqctx.TestingT, resp http.ResponseWriter, req *http.Request) *Base {
|
||||
if !setting.IsInTesting {
|
||||
panic("This function is only for testing")
|
||||
}
|
||||
ctx := reqctx.NewRequestContextForTest(req.Context())
|
||||
ctx := reqctx.NewRequestContextForTest(t)
|
||||
*req = *req.WithContext(ctx)
|
||||
return NewBaseContext(resp, req)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestRedirect(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
resp := httptest.NewRecorder()
|
||||
b := NewBaseContextForTest(resp, req)
|
||||
b := NewBaseContextForTest(t, resp, req)
|
||||
resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String())
|
||||
b.Redirect(c.url)
|
||||
has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy"
|
||||
@@ -39,7 +39,7 @@ func TestRedirect(t *testing.T) {
|
||||
req, _ = http.NewRequest(http.MethodGet, "/", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
req.Header.Add("X-Gitea-Fetch-Action", "1")
|
||||
b := NewBaseContextForTest(resp, req)
|
||||
b := NewBaseContextForTest(t, resp, req)
|
||||
b.Redirect("/other")
|
||||
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
|
||||
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestRedirectToCurrentSite(t *testing.T) {
|
||||
t.Run(c.location, func(t *testing.T) {
|
||||
req := &http.Request{URL: &url.URL{Path: "/"}}
|
||||
resp := httptest.NewRecorder()
|
||||
base := NewBaseContextForTest(resp, req)
|
||||
base := NewBaseContextForTest(t, resp, req)
|
||||
ctx := NewWebContext(base, nil, nil)
|
||||
ctx.RedirectToCurrentSite(c.location)
|
||||
redirect := test.RedirectURL(resp)
|
||||
@@ -58,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
|
||||
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
|
||||
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(t), req)
|
||||
|
||||
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
|
||||
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/private"
|
||||
@@ -23,6 +24,7 @@ type PrivateContext struct {
|
||||
*Base
|
||||
Override context.Context
|
||||
|
||||
Doer *user_model.User
|
||||
Repo *Repository
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func mockRequest(t *testing.T, reqPath string) *http.Request {
|
||||
requestURL, err := url.Parse(path)
|
||||
assert.NoError(t, err)
|
||||
req := &http.Request{Method: method, Host: requestURL.Host, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}}
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(req.Context()))
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t))
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
@@ -846,19 +847,21 @@ func ToGitHook(h *git.Hook) *api.GitHook {
|
||||
}
|
||||
}
|
||||
|
||||
// ToDeployKey convert asymkey_model.DeployKey to api.DeployKey
|
||||
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *asymkey_model.DeployKey) *api.DeployKey {
|
||||
// ToDeployKey convert deploykey_model.DeployKey to api.DeployKey
|
||||
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *deploykey_model.DeployKey) *api.DeployKey {
|
||||
k := &api.DeployKey{
|
||||
ID: deployKey.ID,
|
||||
KeyID: deployKey.KeyID,
|
||||
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
|
||||
Title: deployKey.Name,
|
||||
Created: deployKey.CreatedUnix.AsTime(),
|
||||
ReadOnly: deployKey.Mode == perm.AccessModeRead, // All deploy keys are read-only.
|
||||
ID: deployKey.ID,
|
||||
KeyType: util.Iif(deployKey.KeyType == deploykey_model.KeyTypeSSH, "ssh", "token"),
|
||||
KeyID: deployKey.KeyID,
|
||||
Token: deployKey.Token,
|
||||
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
|
||||
Title: deployKey.Name,
|
||||
Fingerprint: deployKey.Fingerprint,
|
||||
Created: deployKey.CreatedUnix.AsTime(),
|
||||
ReadOnly: deployKey.IsReadOnly(),
|
||||
}
|
||||
if err := deployKey.LoadPublicKey(ctx); err == nil {
|
||||
if deployKey.KeyType == deploykey_model.KeyTypeSSH && deployKey.LoadPublicKey(ctx) == nil {
|
||||
k.Key = deployKey.PublicKey.Content
|
||||
k.Fingerprint = deployKey.PublicKey.Fingerprint
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
@@ -576,3 +576,10 @@ type SaveTopicForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Topics []string `binding:"topics;Required;"`
|
||||
}
|
||||
|
||||
// AddDeployTokenForm form for adding a deploy token to a repository
|
||||
type AddDeployTokenForm struct {
|
||||
middleware.FormDefaultValidator
|
||||
Title string `binding:"Required;MaxSize(50)"`
|
||||
IsWritable bool
|
||||
}
|
||||
|
||||
@@ -49,16 +49,18 @@ type requestContext struct {
|
||||
|
||||
// Claims is a JWT Token Claims
|
||||
type Claims struct {
|
||||
RepoID int64
|
||||
Op string
|
||||
UserID int64
|
||||
RepoID int64
|
||||
Op string
|
||||
UserID int64
|
||||
UserExtDoerData string
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AuthTokenOptions struct {
|
||||
Op string
|
||||
UserID int64
|
||||
RepoID int64
|
||||
Op string
|
||||
UserID int64
|
||||
UserExtDoerData string
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
|
||||
@@ -68,9 +70,10 @@ func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
RepoID: opts.RepoID,
|
||||
Op: opts.Op,
|
||||
UserID: opts.UserID,
|
||||
RepoID: opts.RepoID,
|
||||
Op: opts.Op,
|
||||
UserID: opts.UserID,
|
||||
UserExtDoerData: opts.UserExtDoerData,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
@@ -544,15 +547,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
|
||||
accessMode = perm_model.AccessModeWrite
|
||||
}
|
||||
|
||||
if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
|
||||
perm, err := access_model.GetActionsUserRepoPermission(ctx, repository, ctx.Doer, taskID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetActionsUserRepoPermission for task[%d] Error: %v", taskID, err)
|
||||
return false
|
||||
}
|
||||
return perm.CanAccess(accessMode, unit.TypeCode)
|
||||
}
|
||||
|
||||
// it works for both anonymous request and signed-in user, then perm.CanAccess will do the permission check
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repository, ctx.Doer)
|
||||
if err != nil {
|
||||
@@ -604,9 +598,9 @@ func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repo
|
||||
return nil, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(ctx, claims.UserID)
|
||||
u, err := user_model.GetDoerUser(ctx, claims.UserID, claims.UserExtDoerData)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
|
||||
log.Error("Unable to GetDoerUser[%d]: Error: %v", claims.UserID, err)
|
||||
return nil, err
|
||||
}
|
||||
if !u.IsActive || u.ProhibitLogin {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -101,4 +102,23 @@ func TestAuthenticate(t *testing.T) {
|
||||
err := handleLFSTokenTestPerm("upload", 2, repo1, perm_model.AccessModeWrite)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
// a deploy-key doer has no user row, so the token must carry its ext doer data to stay redeemable
|
||||
t.Run("handleLFSToken resolves deploy-key doers", func(t *testing.T) {
|
||||
key, err := deploykey_model.AddDeployKeyToken(t.Context(), repo1.ID, "lfs", perm_model.AccessModeRead)
|
||||
require.NoError(t, err)
|
||||
doer := user_model.NewDeployKeyUserWithKeyID(key.ID)
|
||||
getDoerToken := func(op string) string {
|
||||
s, _ := GetLFSAuthTokenWithBearer(AuthTokenOptions{Op: op, UserID: doer.ID, UserExtDoerData: doer.ExtDoerData.EncodeToString(), RepoID: repo1.ID})
|
||||
_, token, _ := strings.Cut(s, " ")
|
||||
return token
|
||||
}
|
||||
|
||||
u, err := handleLFSToken(ctx, getDoerToken("download"), repo1, perm_model.AccessModeRead)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user_model.DeployKeyUserID, u.ID)
|
||||
|
||||
_, err = handleLFSToken(ctx, getDoerToken("upload"), repo1, perm_model.AccessModeWrite)
|
||||
assert.ErrorContains(t, err, "no permission to access the repository")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestRenderHelperMention(t *testing.T) {
|
||||
// when using web context, use user.IsUserVisibleToViewer to check
|
||||
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||
assert.NoError(t, err)
|
||||
base := gitea_context.NewBaseContextForTest(httptest.NewRecorder(), req)
|
||||
base := gitea_context.NewBaseContextForTest(t, httptest.NewRecorder(), req)
|
||||
giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
|
||||
|
||||
assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
|
||||
|
||||
@@ -96,13 +96,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
|
||||
if opts.RefFullName.IsTag() {
|
||||
if pusher == nil || pusher.ID != opts.PusherID {
|
||||
if opts.PusherID == user_model.ActionsUserID {
|
||||
pusher = user_model.NewActionsUser()
|
||||
} else {
|
||||
var err error
|
||||
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tagName := opts.RefFullName.TagName()
|
||||
@@ -143,13 +138,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
}
|
||||
} else if opts.RefFullName.IsBranch() {
|
||||
if pusher == nil || pusher.ID != opts.PusherID {
|
||||
if opts.PusherID == user_model.ActionsUserID {
|
||||
pusher = user_model.NewActionsUser()
|
||||
} else {
|
||||
var err error
|
||||
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
templates/repo/settings/deploy_key_list.tmpl
Normal file
44
templates/repo/settings/deploy_key_list.tmpl
Normal file
@@ -0,0 +1,44 @@
|
||||
{{if .RepoDeployKeys}}
|
||||
<div class="flex-divided-list items-with-main">
|
||||
{{range $key := .RepoDeployKeys}}
|
||||
<div class="item">
|
||||
<div class="item-leading">
|
||||
{{$usedRecently := and $key.HasUsed $key.HasRecentActivity}}
|
||||
<span class="{{if $usedRecently}}tw-text-green{{end}}" {{if $usedRecently}}data-tooltip-content="{{ctx.Locale.Tr "settings.key_state_desc"}}"{{end}}>
|
||||
{{svg (Iif $key.IsKeyTypeToken "octicon-key-asterisk" "octicon-key") 32}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{$key.Name}}</div>
|
||||
{{if $key.Fingerprint}}
|
||||
<div class="item-body">{{$key.Fingerprint}}</div>
|
||||
{{end}}
|
||||
<div class="item-body">
|
||||
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $key.CreatedUnix)}}
|
||||
·
|
||||
{{if $key.HasUsed}}{{ctx.Locale.Tr "settings.last_used"}}
|
||||
<span {{if $key.HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort $key.UpdatedUnix}}</span>
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "settings.no_activity"}}
|
||||
{{end}}
|
||||
·
|
||||
{{ctx.Locale.Tr "settings.can_read_info"}}
|
||||
{{if not $key.IsReadOnly}} · {{ctx.Locale.Tr "settings.can_write_info"}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-trailing">
|
||||
{{if $key.IsKeyTypeToken}}
|
||||
<button class="ui tiny button link-action" data-modal-confirm="#repo-deploy-token-regenerate-modal" data-url="{{ctx.RootData.Link}}/regenerate-token?id={{$key.ID}}">
|
||||
{{svg "octicon-sync"}} {{ctx.Locale.Tr "settings.regenerate_token"}}
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="ui red tiny button link-action" data-modal-confirm="#repo-deploy-key-delete-modal" data-url="{{ctx.RootData.Link}}/delete?id={{$key.ID}}">
|
||||
{{ctx.Locale.Tr "settings.delete_key"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "repo.settings.no_deploy_keys"}}
|
||||
{{end}}
|
||||
@@ -1,92 +1,75 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings")}}
|
||||
<div class="repo-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.deploy_keys"}}
|
||||
<div class="ui right">
|
||||
<div class="repo-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.deploy_keys"}}
|
||||
<div class="ui right">
|
||||
{{if not .DisableSSH}}
|
||||
<button class="ui primary tiny show-panel toggle button" data-panel="#add-deploy-key-panel">{{ctx.Locale.Tr "repo.settings.add_deploy_key"}}</button>
|
||||
<button class="ui primary compact tiny button show-panel toggle" data-panel="#add-deploy-key-ssh-panel" data-panel-hide="#add-deploy-key-token-panel">{{ctx.Locale.Tr "repo.settings.add_deploy_key_ssh"}}</button>
|
||||
{{else}}
|
||||
<button class="ui primary tiny button disabled">{{ctx.Locale.Tr "settings.ssh_disabled"}}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="tw-hidden tw-mb-4" id="add-deploy-key-panel">
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}" method="post">
|
||||
<div class="field">
|
||||
{{ctx.Locale.Tr "repo.settings.deploy_key_desc"}}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ssh-key-title">{{ctx.Locale.Tr "repo.settings.title"}}</label>
|
||||
<input id="ssh-key-title" name="title" value="{{.title}}" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ssh-key-content">{{ctx.Locale.Tr "repo.settings.deploy_key_content"}}</label>
|
||||
<textarea id="ssh-key-content" name="content" placeholder="{{ctx.Locale.Tr "settings.key_content_ssh_placeholder"}}" required>{{.content}}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input id="ssh-key-is-writable" name="is_writable" type="checkbox" value="1">
|
||||
<label for="ssh-key-is-writable">
|
||||
{{ctx.Locale.Tr "repo.settings.is_writable"}}
|
||||
</label>
|
||||
<small class="tw-pl-[26px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button">
|
||||
{{ctx.Locale.Tr "repo.settings.add_deploy_key"}}
|
||||
</button>
|
||||
<button class="ui hide-panel button" data-panel="#add-deploy-key-panel">
|
||||
{{ctx.Locale.Tr "cancel"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .RepoDeployKeys}}
|
||||
<div class="flex-divided-list items-with-main">
|
||||
{{range $deployKey := .RepoDeployKeys}}
|
||||
<div class="item">
|
||||
<div class="item-leading">
|
||||
<span class="{{if $deployKey.HasRecentActivity}}tw-text-green{{end}}"
|
||||
{{if $deployKey.HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.key_state_desc"}}"{{end}}
|
||||
>{{svg "octicon-key" 32}}</span>
|
||||
</div>
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{$deployKey.Name}}</div>
|
||||
<div class="item-body">
|
||||
{{$deployKey.Fingerprint}}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $deployKey.CreatedUnix)}}
|
||||
<span class="tw-mx-2">-</span>
|
||||
{{svg "octicon-info"}}
|
||||
{{if $deployKey.HasUsed}}
|
||||
{{ctx.Locale.Tr "settings.last_used"}}
|
||||
<span {{if $deployKey.HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort $deployKey.UpdatedUnix}}</span>
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "settings.no_activity"}}
|
||||
{{end}}
|
||||
<span class="tw-mx-2">-</span>
|
||||
<span>{{ctx.Locale.Tr "settings.can_read_info"}}{{if not $deployKey.IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-trailing">
|
||||
<button class="ui red tiny button link-action" data-modal-confirm="#repo-deploy-key-delete-modal" data-url="{{$.Link}}/delete?id={{$deployKey.ID}}">
|
||||
{{ctx.Locale.Tr "settings.delete_key"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "repo.settings.no_deploy_keys"}}
|
||||
<button class="ui primary compact tiny button disabled">{{ctx.Locale.Tr "settings.ssh_disabled"}}</button>
|
||||
{{end}}
|
||||
<button class="ui primary compact tiny button show-panel toggle" data-panel="#add-deploy-key-token-panel" data-panel-hide="#add-deploy-key-ssh-panel">{{ctx.Locale.Tr "repo.settings.generate_deploy_token"}}</button>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="tw-hidden tw-mb-4" id="add-deploy-key-ssh-panel">
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}" method="post">
|
||||
<div class="field">{{ctx.Locale.Tr "repo.settings.deploy_key_ssh_desc"}}</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.title"}}</label>
|
||||
<input name="title" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "settings.key_content"}}</label>
|
||||
<textarea name="content" placeholder="{{ctx.Locale.Tr "settings.key_content_ssh_placeholder"}}" required></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="is_writable" type="checkbox" value="1">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.is_writable"}}</label>
|
||||
<small class="tw-pl-[20px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.add_deploy_key_ssh"}}</button>
|
||||
<button class="ui hide-panel button" data-panel="#add-deploy-key-ssh-panel">{{ctx.Locale.Tr "cancel"}}</button>
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="tw-hidden tw-mb-4" id="add-deploy-key-token-panel">
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}/generate-token" method="post">
|
||||
<div class="field">{{ctx.Locale.Tr "repo.settings.deploy_key_token_desc"}}</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.title"}}</label>
|
||||
<input name="title" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="is_writable" type="checkbox" value="1">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.is_writable"}}</label>
|
||||
<small class="tw-pl-[20px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.generate_deploy_token"}}</button>
|
||||
<button class="ui hide-panel button" data-panel="#add-deploy-key-token-panel">{{ctx.Locale.Tr "cancel"}}</button>
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
{{template "repo/settings/deploy_key_list" dict "RepoDeployKeys" .RepoDeployKeys}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui small modal" id="repo-deploy-key-delete-modal">
|
||||
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.settings.deploy_key_deletion"}}</div>
|
||||
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "remove"}}</div>
|
||||
<div class="content"><p>{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}</p></div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
|
||||
<div class="ui small modal" id="repo-deploy-token-regenerate-modal">
|
||||
<div class="header">{{svg "octicon-sync"}} {{ctx.Locale.Tr "settings.regenerate_token"}}</div>
|
||||
<div class="content"><p>{{ctx.Locale.Tr "repo.settings.regenerate_deploy_token_desc"}}</p></div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
|
||||
92
templates/swagger/v1-openapi3.generated.json
generated
92
templates/swagger/v1-openapi3.generated.json
generated
@@ -3973,6 +3973,26 @@
|
||||
"type": "object",
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"CreateDeployKeyTokenOption": {
|
||||
"properties": {
|
||||
"read_only": {
|
||||
"description": "Describe if the token has only read access or read/write",
|
||||
"type": "boolean",
|
||||
"x-go-name": "ReadOnly"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title of the token to add",
|
||||
"type": "string",
|
||||
"uniqueItems": true,
|
||||
"x-go-name": "Title"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"type": "object",
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"CreateEmailOption": {
|
||||
"description": "CreateEmailOption options when creating email addresses",
|
||||
"properties": {
|
||||
@@ -4227,7 +4247,6 @@
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"CreateKeyOption": {
|
||||
"description": "CreateKeyOption options when creating a key",
|
||||
"properties": {
|
||||
"key": {
|
||||
"description": "An armored SSH key to add",
|
||||
@@ -5195,10 +5214,9 @@
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"DeployKey": {
|
||||
"description": "DeployKey a deploy key",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"description": "Created is the time when the deploy key was added",
|
||||
"description": "Created is the time when the deploy-key was added",
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"x-go-name": "Created"
|
||||
@@ -5209,7 +5227,7 @@
|
||||
"x-go-name": "Fingerprint"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the deploy key",
|
||||
"description": "ID is the unique identifier for the deploy-key",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "ID"
|
||||
@@ -5225,6 +5243,15 @@
|
||||
"type": "integer",
|
||||
"x-go-name": "KeyID"
|
||||
},
|
||||
"key_type": {
|
||||
"description": "Type tells whether the key authenticates over SSH or with a token over HTTPS",
|
||||
"enum": [
|
||||
"ssh",
|
||||
"token"
|
||||
],
|
||||
"type": "string",
|
||||
"x-go-name": "KeyType"
|
||||
},
|
||||
"read_only": {
|
||||
"description": "ReadOnly indicates if the key has read-only access",
|
||||
"type": "boolean",
|
||||
@@ -5238,8 +5265,13 @@
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
},
|
||||
"token": {
|
||||
"description": "Token is the plaintext token of an HTTPS key, only returned when it is created",
|
||||
"type": "string",
|
||||
"x-go-name": "Token"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL is the API URL for this deploy key",
|
||||
"description": "URL is the API URL for this deploy-key",
|
||||
"format": "uri",
|
||||
"type": "string",
|
||||
"x-go-name": "URL"
|
||||
@@ -26543,6 +26575,56 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/keys/tokens": {
|
||||
"post": {
|
||||
"operationId": "repoCreateDeployToken",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repo",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateDeployKeyTokenOption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-originalParamName": "body"
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"$ref": "#/components/responses/DeployKey"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/validationError"
|
||||
}
|
||||
},
|
||||
"summary": "Add a deploy token to a repository, it authenticates git over HTTPS",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/keys/{id}": {
|
||||
"delete": {
|
||||
"operationId": "repoDeleteKey",
|
||||
|
||||
91
templates/swagger/v1-swagger.generated.json
generated
91
templates/swagger/v1-swagger.generated.json
generated
@@ -14457,6 +14457,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/keys/tokens": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Add a deploy token to a repository, it authenticates git over HTTPS",
|
||||
"operationId": "repoCreateDeployToken",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/CreateDeployKeyTokenOption"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"$ref": "#/responses/DeployKey"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/keys/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -26937,6 +26986,26 @@
|
||||
},
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"CreateDeployKeyTokenOption": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"read_only": {
|
||||
"description": "Describe if the token has only read access or read/write",
|
||||
"type": "boolean",
|
||||
"x-go-name": "ReadOnly"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title of the token to add",
|
||||
"type": "string",
|
||||
"uniqueItems": true,
|
||||
"x-go-name": "Title"
|
||||
}
|
||||
},
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"CreateEmailOption": {
|
||||
"description": "CreateEmailOption options when creating email addresses",
|
||||
"type": "object",
|
||||
@@ -27190,7 +27259,6 @@
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"CreateKeyOption": {
|
||||
"description": "CreateKeyOption options when creating a key",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
@@ -28186,11 +28254,10 @@
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"DeployKey": {
|
||||
"description": "DeployKey a deploy key",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"description": "Created is the time when the deploy key was added",
|
||||
"description": "Created is the time when the deploy-key was added",
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"x-go-name": "Created"
|
||||
@@ -28201,7 +28268,7 @@
|
||||
"x-go-name": "Fingerprint"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the deploy key",
|
||||
"description": "ID is the unique identifier for the deploy-key",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "ID"
|
||||
@@ -28217,6 +28284,15 @@
|
||||
"format": "int64",
|
||||
"x-go-name": "KeyID"
|
||||
},
|
||||
"key_type": {
|
||||
"description": "Type tells whether the key authenticates over SSH or with a token over HTTPS",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ssh",
|
||||
"token"
|
||||
],
|
||||
"x-go-name": "KeyType"
|
||||
},
|
||||
"read_only": {
|
||||
"description": "ReadOnly indicates if the key has read-only access",
|
||||
"type": "boolean",
|
||||
@@ -28230,8 +28306,13 @@
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
},
|
||||
"token": {
|
||||
"description": "Token is the plaintext token of an HTTPS key, only returned when it is created",
|
||||
"type": "string",
|
||||
"x-go-name": "Token"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL is the API URL for this deploy key",
|
||||
"description": "URL is the API URL for this deploy-key",
|
||||
"type": "string",
|
||||
"x-go-name": "URL"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -66,7 +67,7 @@ func TestCreateReadOnlyDeployKey(t *testing.T) {
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
newDeployKey := DecodeJSON(t, resp, &api.DeployKey{})
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{
|
||||
ID: newDeployKey.ID,
|
||||
Name: rawKeyBody.Title,
|
||||
Mode: perm.AccessModeRead,
|
||||
@@ -103,7 +104,7 @@ func TestCreateReadWriteDeployKey(t *testing.T) {
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
newDeployKey := DecodeJSON(t, resp, &api.DeployKey{})
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{
|
||||
ID: newDeployKey.ID,
|
||||
Name: rawKeyBody.Title,
|
||||
Mode: perm.AccessModeWrite,
|
||||
@@ -204,3 +205,31 @@ func TestCreateUserKey(t *testing.T) {
|
||||
fingerprintPublicKeys = DecodeJSON(t, resp, []api.PublicKey{})
|
||||
assert.Empty(t, fingerprintPublicKeys)
|
||||
}
|
||||
|
||||
func TestCreateDeployToken(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
|
||||
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, repoOwner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", keysURL+"/tokens", api.CreateDeployKeyTokenOption{Title: "ci", ReadOnly: true}).
|
||||
AddTokenAuth(token)
|
||||
created := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.DeployKey{})
|
||||
assert.NotEmpty(t, created.Token)
|
||||
assert.True(t, created.ReadOnly)
|
||||
|
||||
// a token is listed and deleted like a deploy key, but it is never readable again
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", keysURL).AddTokenAuth(token), http.StatusOK)
|
||||
listed := DecodeJSON(t, resp, []api.DeployKey{})
|
||||
assert.Len(t, listed, 1)
|
||||
assert.NotContains(t, resp.Body.String(), created.Token)
|
||||
assert.Equal(t, created.Fingerprint, listed[0].Fingerprint)
|
||||
assert.Contains(t, created.Fingerprint, "********")
|
||||
|
||||
MakeRequest(t, NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", keysURL, created.ID)).AddTokenAuth(token), http.StatusNoContent)
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", keysURL).AddTokenAuth(token), http.StatusOK)
|
||||
assert.Empty(t, DecodeJSON(t, resp, []api.DeployKey{}))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/private"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -27,7 +28,7 @@ func TestAPIPrivateNoServ(t *testing.T) {
|
||||
assert.Equal(t, "user2@localhost", key.Name)
|
||||
|
||||
keyContent := "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"
|
||||
deployKey, err := asymkey_model.AddDeployKey(ctx, 1, "test-deploy", keyContent, perm.AccessModeRead)
|
||||
deployKey, err := deploykey_model.AddDeployKeySSH(ctx, 1, "test-deploy", keyContent, perm.AccessModeRead)
|
||||
assert.NoError(t, err)
|
||||
|
||||
key, user, err = private.ServNoCommand(ctx, deployKey.KeyID)
|
||||
@@ -47,9 +48,8 @@ func TestAPIPrivateServ(t *testing.T) {
|
||||
results, extra := private.ServCommand(ctx, 1, "user2", "repo1", perm.AccessModeWrite, "git-upload-pack", "")
|
||||
assert.NoError(t, extra.Error)
|
||||
assert.False(t, results.IsWiki)
|
||||
assert.Zero(t, results.DeployKeyID)
|
||||
assert.Equal(t, int64(1), results.KeyID)
|
||||
assert.Equal(t, "user2@localhost", results.KeyName)
|
||||
assert.Empty(t, results.UserExtDoerData)
|
||||
assert.Equal(t, int64(1), results.PublicKeyID)
|
||||
assert.Equal(t, "user2", results.UserName)
|
||||
assert.Equal(t, int64(2), results.UserID)
|
||||
assert.Equal(t, "user2", results.OwnerName)
|
||||
@@ -70,9 +70,8 @@ func TestAPIPrivateServ(t *testing.T) {
|
||||
results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "")
|
||||
assert.NoError(t, extra.Error)
|
||||
assert.False(t, results.IsWiki)
|
||||
assert.Zero(t, results.DeployKeyID)
|
||||
assert.Equal(t, int64(1), results.KeyID)
|
||||
assert.Equal(t, "user2@localhost", results.KeyName)
|
||||
assert.Empty(t, results.UserExtDoerData)
|
||||
assert.Equal(t, int64(1), results.PublicKeyID)
|
||||
assert.Equal(t, "user2", results.UserName)
|
||||
assert.Equal(t, int64(2), results.UserID)
|
||||
assert.Equal(t, "user15", results.OwnerName)
|
||||
@@ -86,18 +85,18 @@ func TestAPIPrivateServ(t *testing.T) {
|
||||
|
||||
// Add reading deploy key
|
||||
testContent := "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"
|
||||
deployKey, err := asymkey_model.AddDeployKey(ctx, 19 /* repo id */, "test-deploy", testContent, perm.AccessModeRead)
|
||||
deployKey, err := deploykey_model.AddDeployKeySSH(ctx, 19 /* repo id */, "test-deploy", testContent, perm.AccessModeRead)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Can pull from repo we're a deploy-key for
|
||||
deployKeyUser := user.NewDeployKeyUser()
|
||||
results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "")
|
||||
assert.NoError(t, extra.Error)
|
||||
assert.False(t, results.IsWiki)
|
||||
assert.NotZero(t, results.DeployKeyID)
|
||||
assert.Equal(t, deployKey.KeyID, results.KeyID)
|
||||
assert.Equal(t, "test-deploy", results.KeyName)
|
||||
assert.Equal(t, "user15", results.UserName)
|
||||
assert.Equal(t, int64(15), results.UserID)
|
||||
assert.NotEmpty(t, results.UserExtDoerData)
|
||||
assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
|
||||
assert.Equal(t, deployKeyUser.Name, results.UserName)
|
||||
assert.Equal(t, deployKeyUser.ID, results.UserID)
|
||||
assert.Equal(t, "user15", results.OwnerName)
|
||||
assert.Equal(t, "big_test_private_1", results.RepoName)
|
||||
assert.Equal(t, int64(19), results.RepoID)
|
||||
@@ -119,7 +118,7 @@ func TestAPIPrivateServ(t *testing.T) {
|
||||
|
||||
// Add writing deploy key
|
||||
testContent = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"
|
||||
deployKey, err = asymkey_model.AddDeployKey(ctx, 20 /* repo id */, "test-deploy", testContent, perm.AccessModeWrite)
|
||||
deployKey, err = deploykey_model.AddDeployKeySSH(ctx, 20 /* repo id */, "test-deploy", testContent, perm.AccessModeWrite)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Cannot push to a private repo with reading key
|
||||
@@ -131,11 +130,10 @@ func TestAPIPrivateServ(t *testing.T) {
|
||||
results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "")
|
||||
assert.NoError(t, extra.Error)
|
||||
assert.False(t, results.IsWiki)
|
||||
assert.NotZero(t, results.DeployKeyID)
|
||||
assert.Equal(t, deployKey.KeyID, results.KeyID)
|
||||
assert.Equal(t, "test-deploy", results.KeyName)
|
||||
assert.Equal(t, "user15", results.UserName)
|
||||
assert.Equal(t, int64(15), results.UserID)
|
||||
assert.NotEmpty(t, results.UserExtDoerData)
|
||||
assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
|
||||
assert.Equal(t, deployKeyUser.Name, results.UserName)
|
||||
assert.Equal(t, deployKeyUser.ID, results.UserID)
|
||||
assert.Equal(t, "user15", results.OwnerName)
|
||||
assert.Equal(t, "big_test_private_2", results.RepoName)
|
||||
assert.Equal(t, int64(20), results.RepoID)
|
||||
@@ -144,11 +142,10 @@ func TestAPIPrivateServ(t *testing.T) {
|
||||
results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeWrite, "git-upload-pack", "")
|
||||
assert.NoError(t, extra.Error)
|
||||
assert.False(t, results.IsWiki)
|
||||
assert.NotZero(t, results.DeployKeyID)
|
||||
assert.Equal(t, deployKey.KeyID, results.KeyID)
|
||||
assert.Equal(t, "test-deploy", results.KeyName)
|
||||
assert.Equal(t, "user15", results.UserName)
|
||||
assert.Equal(t, int64(15), results.UserID)
|
||||
assert.NotEmpty(t, results.UserExtDoerData)
|
||||
assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
|
||||
assert.Equal(t, deployKeyUser.Name, results.UserName)
|
||||
assert.Equal(t, deployKeyUser.ID, results.UserID)
|
||||
assert.Equal(t, "user15", results.OwnerName)
|
||||
assert.Equal(t, "big_test_private_2", results.RepoName)
|
||||
assert.Equal(t, int64(20), results.RepoID)
|
||||
|
||||
76
tests/integration/deploy_token_test.go
Normal file
76
tests/integration/deploy_token_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func doSSHLFSAccessTest(_ APITestContext, keyID int64) func(*testing.T) {
|
||||
_, err := cmd.Output()
|
||||
var errExit *exec.ExitError
|
||||
require.ErrorAs(t, err, &errExit) // inaccessible, error
|
||||
assert.Contains(t, string(errExit.Stderr), fmt.Sprintf(`User 2 with key %d:test-key has no "write" permission for user5/repo4`, keyID))
|
||||
assert.Contains(t, string(errExit.Stderr), `has no "write" permission for user5/repo4`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
// if it has "toggle" class, it toggles the panel
|
||||
e.preventDefault();
|
||||
const sel = el.getAttribute('data-panel')!;
|
||||
const selHide = el.getAttribute('data-panel-hide');
|
||||
if (selHide) hideElem(selHide);
|
||||
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
|
||||
for (const elem of elems) {
|
||||
if (isElemVisible(elem as HTMLElement)) {
|
||||
|
||||
@@ -38,7 +38,7 @@ function patchLabels(parent: ParentNode, containerSelector: string, labelSelecto
|
||||
// link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
|
||||
export function initAriaLabels(container: ParentNode) {
|
||||
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
|
||||
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select', 'data-field-patched');
|
||||
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select, :scope > textarea', 'data-field-patched');
|
||||
}
|
||||
|
||||
export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {
|
||||
|
||||
Reference in New Issue
Block a user