feat: add deploy tokens (#37306)

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

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Mythos <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ToastyTheBot
2026-08-27 03:32:44 +08:00
committed by GitHub
parent 3c4d5a6a5c
commit 646ea0f253
76 changed files with 1594 additions and 831 deletions
+1 -3
View File
@@ -47,9 +47,7 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size
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()
}
+21 -11
View File
@@ -159,6 +159,11 @@ type User struct {
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"`
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
// When the user model is used as a doer (all existing code does so), the doer can have extra details.
// * Actions task doer needs to bind to the task
// * Deploy-key doer needs to bind to the key
ExtDoerData ExtDoerData `xorm:"-"`
}
// Meta defines the meta information of a user, to be stored in the K/V table
@@ -418,9 +423,9 @@ func (u *User) IsOrganization() bool {
return u.Type == UserTypeOrganization
}
// IsIndividual returns true if user is actually a individual user.
// IsIndividual returns true if user is actually an individual user.
func (u *User) IsIndividual() bool {
return u.Type == UserTypeIndividual
return u.ID > 0 && u.Type == UserTypeIndividual
}
// IsTypeBot returns whether the user is of type bot
@@ -513,9 +518,8 @@ func (u *User) GitName() string {
}
// IsMailable checks if a user is eligible to receive emails.
// System users like Ghost and Gitea Actions are excluded.
func (u *User) IsMailable() bool {
return u.IsActive && !u.IsGiteaActions() && !u.IsGhost()
return u.ID > 0 && u.IsActive && u.IsIndividual()
}
// IsUserExist checks if given username exist,
@@ -551,10 +555,11 @@ type globalVarsStruct struct {
emailToReplacer *strings.Replacer
emailRegexp *regexp.Regexp
systemUserNewFuncs map[int64]func() *User
systemUserNameIdMap map[string]int64
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
return &globalVarsStruct{
ret := &globalVarsStruct{
// Note: The set of characters here can safely expand without a breaking change,
// but characters removed from this set can cause user account linking to break
customCharsReplacement: strings.NewReplacer("Æ", "AE"),
@@ -573,12 +578,17 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
";", "",
),
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
systemUserNewFuncs: map[int64]func() *User{
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
},
}
userFuncs := []func() *User{NewGhostUser, NewActionsUser, NewDeployKeyUser}
ret.systemUserNewFuncs = map[int64]func() *User{}
ret.systemUserNameIdMap = map[string]int64{}
for _, fn := range userFuncs {
u := fn()
ret.systemUserNewFuncs[u.ID] = fn
ret.systemUserNameIdMap[u.LowerName] = u.ID
}
return ret
})
// NormalizeUserName only takes the name part if it is an email address, transforms it diacritics to ASCII characters.
@@ -1023,7 +1033,7 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err
}
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
if id < 0 {
if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok {
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"strconv"
"strings"
)
type ExtDoerData interface {
EncodeToString() string
DecodeFromString(string) error
}
type extDoerGiteaActions struct {
TaskID int64
}
var _ ExtDoerData = (*extDoerGiteaActions)(nil)
func (e *extDoerGiteaActions) EncodeToString() string {
return "gitea-actions:" + strconv.FormatInt(e.TaskID, 10)
}
func (e *extDoerGiteaActions) DecodeFromString(s string) (err error) {
idStr, _ := strings.CutPrefix(s, "gitea-actions:")
e.TaskID, err = strconv.ParseInt(idStr, 10, 64)
return err
}
type extDoerDeployKey struct {
DeployKeyID int64
}
var _ ExtDoerData = (*extDoerDeployKey)(nil)
func (e *extDoerDeployKey) EncodeToString() string {
return "deploy-key:" + strconv.FormatInt(e.DeployKeyID, 10)
}
func (e *extDoerDeployKey) DecodeFromString(s string) (err error) {
idStr, _ := strings.CutPrefix(s, "deploy-key:")
e.DeployKeyID, err = strconv.ParseInt(idStr, 10, 64)
return err
}
+9 -12
View File
@@ -31,18 +31,15 @@ func GetUsersMapByIDs(ctx context.Context, userIDs []int64) (map[int64]*User, er
}
func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
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
}
+63 -36
View File
@@ -4,7 +4,7 @@
package user
import (
"strconv"
"context"
"strings"
"gitea.dev/modules/structs"
@@ -32,59 +32,86 @@ func (u *User) IsGhost() bool {
return u.ID == GhostUserID && u.Name == GhostUserName
}
// newSystemUser creates and returns a fake user for system use.
// The builtin username can be wrapped in parentheses to avoid conflicts with real usernames.
func newSystemUser(id int64, name, fullName string) *User {
return &User{
ID: id,
Name: name,
LowerName: strings.ToLower(name),
IsActive: true,
FullName: fullName,
Type: UserTypeBot,
Visibility: structs.VisibleTypePublic,
}
}
const (
ActionsUserID int64 = -2
ActionsUserName = "gitea-actions"
ActionsUserEmail = "teabot@gitea.io"
ActionsUserID int64 = -2
DeployKeyUserID int64 = -3
)
// NewActionsUser creates and returns a fake user for running the actions.
func NewActionsUser() *User {
return &User{
ID: ActionsUserID,
Name: ActionsUserName,
LowerName: ActionsUserName,
IsActive: true,
FullName: "Gitea Actions",
Email: ActionsUserEmail,
KeepEmailPrivate: true,
LoginName: ActionsUserName,
Type: UserTypeBot,
Visibility: structs.VisibleTypePublic,
return newSystemUser(ActionsUserID, "gitea-actions", "Gitea Actions")
}
func GetActionsUserTaskID(u *User) (int64, bool) {
if u == nil || u.ExtDoerData == nil || u.ID != ActionsUserID {
return 0, false
}
extData := u.ExtDoerData.(*extDoerGiteaActions) //nolint:forcetypeassert // must be valid
return extData.TaskID, true
}
func NewActionsUserWithTaskID(id int64) *User {
u := NewActionsUser()
// LoginName is for only internal usage in this case, so it can be moved to other fields in the future
u.LoginSource = -1
u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10)
u.ExtDoerData = &extDoerGiteaActions{TaskID: id}
return u
}
func GetActionsUserTaskID(u *User) (int64, bool) {
if u == nil || u.ID != ActionsUserID {
return 0, false
}
prefix, payload, _ := strings.Cut(u.LoginName, "/")
if prefix != "@"+ActionsUserName {
return 0, false
} else if taskID, err := strconv.ParseInt(payload, 10, 64); err == nil {
return taskID, true
}
return 0, false
func NewDeployKeyUser() *User {
return newSystemUser(DeployKeyUserID, "(deploy-key)", "Deploy Key")
}
func (u *User) IsGiteaActions() bool {
return u != nil && u.ID == ActionsUserID
func GetDeployKeyUserDeployKeyID(u *User) (int64, bool) {
// ok, the function name seems wordy, it is intentionally to distinguish from other "keys" like "public key id"
// it was a mess in the "pre-receive" hook code
if u == nil || u.ExtDoerData == nil || u.ID != DeployKeyUserID {
return 0, false
}
extData := u.ExtDoerData.(*extDoerDeployKey) //nolint:forcetypeassert // must be valid
return extData.DeployKeyID, true
}
func NewDeployKeyUserWithKeyID(id int64) *User {
u := NewDeployKeyUser()
u.ExtDoerData = &extDoerDeployKey{DeployKeyID: id}
return u
}
func GetSystemUserByName(name string) *User {
if strings.EqualFold(name, GhostUserName) {
return NewGhostUser()
}
if strings.EqualFold(name, ActionsUserName) {
return NewActionsUser()
lowerName := strings.ToLower(name)
uid := globalVars().systemUserNameIdMap[lowerName]
if fn := globalVars().systemUserNewFuncs[uid]; fn != nil {
return fn()
}
return nil
}
func GetDoerUser(ctx context.Context, id int64, extDoerData string) (u *User, _ error) {
if id > 0 {
return GetUserByID(ctx, id)
}
switch id {
case ActionsUserID:
u = NewActionsUser()
u.ExtDoerData = &extDoerGiteaActions{}
case DeployKeyUserID:
u = NewDeployKeyUser()
u.ExtDoerData = &extDoerDeployKey{}
default:
return nil, ErrUserNotExist{UID: id}
}
return u, u.ExtDoerData.DecodeFromString(extDoerData)
}
-1
View File
@@ -27,7 +27,6 @@ func TestSystemUser(t *testing.T) {
assert.Equal(t, int64(-2), uid)
assert.Equal(t, "gitea-actions", u.Name)
assert.Equal(t, "gitea-actions", u.LowerName)
assert.True(t, u.IsGiteaActions())
u = GetSystemUserByName("Gitea-actionS")
require.NotNil(t, u)