diff --git a/cmd/hook.go b/cmd/hook.go index 77a8fafaa9e..b0065d2af2c 100644 --- a/cmd/hook.go +++ b/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) diff --git a/cmd/serv.go b/cmd/serv.go index eddbbcb3ab9..1d9ab781268 100644 --- a/cmd/serv.go +++ b/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) } } diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 245a7a44ea4..47e9f8a3250 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -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 } diff --git a/modelmigration/v28/v352.go b/modelmigration/v28/v352.go new file mode 100644 index 00000000000..d79c7399643 --- /dev/null +++ b/modelmigration/v28/v352.go @@ -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 +} diff --git a/models/actions/run_attempt_list.go b/models/actions/run_attempt_list.go index 9c125d559e0..f25e8fb0af8 100644 --- a/models/actions/run_attempt_list.go +++ b/models/actions/run_attempt_list.go @@ -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 } diff --git a/models/asymkey/error.go b/models/asymkey/error.go index 1a862954a32..347de3fb512 100644 --- a/models/asymkey/error.go +++ b/models/asymkey/error.go @@ -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 diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index 1c7828dd18e..f41ec04dc89 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -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) diff --git a/models/asymkey/ssh_key_deploy.go b/models/asymkey/ssh_key_deploy.go deleted file mode 100644 index 5847fbb495b..00000000000 --- a/models/asymkey/ssh_key_deploy.go +++ /dev/null @@ -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 -} diff --git a/models/deploykey/deploykey.go b/models/deploykey/deploykey.go new file mode 100644 index 00000000000..ce74c4e1413 --- /dev/null +++ b/models/deploykey/deploykey.go @@ -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 +} diff --git a/models/deploykey/deploykey_ssh.go b/models/deploykey/deploykey_ssh.go new file mode 100644 index 00000000000..c072a4f26c1 --- /dev/null +++ b/models/deploykey/deploykey_ssh.go @@ -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}) +} diff --git a/models/deploykey/deploykey_token.go b/models/deploykey/deploykey_token.go new file mode 100644 index 00000000000..e6a78da9b92 --- /dev/null +++ b/models/deploykey/deploykey_token.go @@ -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 +} diff --git a/models/deploykey/deploykey_token_test.go b/models/deploykey/deploykey_token_test.go new file mode 100644 index 00000000000..85cfdfbef4f --- /dev/null +++ b/models/deploykey/deploykey_token_test.go @@ -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)) +} diff --git a/models/deploykey/error.go b/models/deploykey/error.go new file mode 100644 index 00000000000..eef343e6957 --- /dev/null +++ b/models/deploykey/error.go @@ -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 +} diff --git a/models/deploykey/main_test.go b/models/deploykey/main_test.go new file mode 100644 index 00000000000..f4fe1a94703 --- /dev/null +++ b/models/deploykey/main_test.go @@ -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 +} diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index 69f083ccd75..b3af6afc132 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -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 diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index ff677906afe..89ce6dc604c 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -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) } diff --git a/models/user/avatar.go b/models/user/avatar.go index 5b4d1cef41a..6572746b242 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -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() } diff --git a/models/user/user.go b/models/user/user.go index fff576aa860..77da0fcdf2f 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -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 { diff --git a/models/user/user_extdata.go b/models/user/user_extdata.go new file mode 100644 index 00000000000..62d6f5d6a7e --- /dev/null +++ b/models/user/user_extdata.go @@ -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 +} diff --git a/models/user/user_list.go b/models/user/user_list.go index c0802860599..f639a1f71a6 100644 --- a/models/user/user_list.go +++ b/models/user/user_list.go @@ -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 } diff --git a/models/user/user_system.go b/models/user/user_system.go index 5a71553e9ec..aa04015468e 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -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) +} diff --git a/models/user/user_system_test.go b/models/user/user_system_test.go index 3ae9c6e3665..8111e7b54c9 100644 --- a/models/user/user_system_test.go +++ b/models/user/user_system_test.go @@ -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) diff --git a/modules/private/hook.go b/modules/private/hook.go index 0ca1f36e318..9e47f10fc95 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -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 diff --git a/modules/private/serv.go b/modules/private/serv.go index 20768d10196..c61ef20476c 100644 --- a/modules/private/serv.go +++ b/modules/private/serv.go @@ -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 } diff --git a/modules/repository/env.go b/modules/repository/env.go index 5eecb54e07b..d2586eaf54d 100644 --- a/modules/repository/env.go +++ b/modules/repository/env.go @@ -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 } diff --git a/modules/reqctx/datastore.go b/modules/reqctx/datastore.go index 282d1ddd877..d1e7aab1a39 100644 --- a/modules/reqctx/datastore.go +++ b/modules/reqctx/datastore.go @@ -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 } diff --git a/modules/structs/repo_key.go b/modules/structs/repo_key.go index a13cde71fbf..ba9b5e6e621 100644 --- a/modules/structs/repo_key.go +++ b/modules/structs/repo_key.go @@ -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"` } diff --git a/modules/templates/util_render_comment_test.go b/modules/templates/util_render_comment_test.go index e0e912a3f81..5dd5b27a0e5 100644 --- a/modules/templates/util_render_comment_test.go +++ b/modules/templates/util_render_comment_test.go @@ -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)" diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index d9dabf8ec8b..f90ee35fdf0 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -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) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f5b7ed1268b..6cb8a033a0e 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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 push 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", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 1860f8b820f..51fca8e85c6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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()) diff --git a/routers/api/v1/api_test.go b/routers/api/v1/api_test.go new file mode 100644 index 00000000000..8ab4a496e2a --- /dev/null +++ b/routers/api/v1/api_test.go @@ -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) + } +} diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go index 465a775c931..b0da7816e89 100644 --- a/routers/api/v1/repo/key.go +++ b/routers/api/v1/repo/key.go @@ -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 { diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index e87b1761ad8..0522fcec680 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -53,6 +53,9 @@ type swaggerParameterBodies struct { // in:body CreateKeyOption api.CreateKeyOption + // in:body + CreateDeployKeyTokenOption api.CreateDeployKeyTokenOption + // in:body RenameUserOption api.RenameUserOption diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 8343e380773..22bd2b9e494 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -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{ diff --git a/routers/common/errpage_test.go b/routers/common/errpage_test.go index 319ae7c1711..667155b19bf 100644 --- a/routers/common/errpage_test.go +++ b/routers/common/errpage_test.go @@ -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()) }) diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index 9d48292a084..365c4eacc51 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -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 diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go index b465c7f6e8c..a7099aa58df 100644 --- a/routers/private/hook_post_receive_test.go +++ b/routers/private/hook_post_receive_test.go @@ -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, diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index d33b1a32ffb..82e1cd78053 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -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 -} diff --git a/routers/private/hook_pre_receive_test.go b/routers/private/hook_pre_receive_test.go index 3c1c21673f2..60d5513522f 100644 --- a/routers/private/hook_pre_receive_test.go +++ b/routers/private/hook_pre_receive_test.go @@ -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"))) diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go index 73542c6dd76..c56276f6a8e 100644 --- a/routers/private/hook_proc_receive.go +++ b/routers/private/hook_proc_receive.go @@ -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.") diff --git a/routers/private/internal.go b/routers/private/internal.go index 2519b4ee3cb..412c5017eb0 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -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) diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go index c4563c1433e..98bcae51d1d 100644 --- a/routers/private/internal_repo.go +++ b/routers/private/internal_repo.go @@ -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 +} diff --git a/routers/private/key.go b/routers/private/key.go index 2a89e2790a5..46bb311bb74 100644 --- a/routers/private/key.go +++ b/routers/private/key.go @@ -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 } diff --git a/routers/private/serv.go b/routers/private/serv.go index dc1c7c3dd69..f900361819a 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -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.") diff --git a/routers/web/repo/actions/actions_test.go b/routers/web/repo/actions/actions_test.go index 86b4fdb6e4d..5fcf59ada50 100644 --- a/routers/web/repo/actions/actions_test.go +++ b/routers/web/repo/actions/actions_test.go @@ -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", diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index f45155568e4..ee0ed77a799 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -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) } diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index 3620b01a2dc..2ba5307be7e 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -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("%s", 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("%s", key.Token))) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") +} diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index b88940c83be..f8cb64e4f55 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -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}) }) } diff --git a/routers/web/web.go b/routers/web/web.go index ed1593cf8fd..861df9d866a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index dc91e8bdba2..980b50fc88d 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -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. diff --git a/services/agit/agit.go b/services/agit/agit.go index 8b2bb575fd9..2ed2e3642c9 100644 --- a/services/agit/agit.go +++ b/services/agit/agit.go @@ -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() { diff --git a/services/asymkey/deploy_key.go b/services/asymkey/deploy_key.go index 66c22407a14..0a97bd2de2e 100644 --- a/services/asymkey/deploy_key.go +++ b/services/asymkey/deploy_key.go @@ -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) } diff --git a/services/auth/basic.go b/services/auth/basic.go index e0825f88753..d562addfc60 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -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 diff --git a/services/auth/deploy_token.go b/services/auth/deploy_token.go new file mode 100644 index 00000000000..bbe76e557ab --- /dev/null +++ b/services/auth/deploy_token.go @@ -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 +} diff --git a/services/context/base.go b/services/context/base.go index 7fc1100b0c6..bb0798b2423 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -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) } diff --git a/services/context/base_test.go b/services/context/base_test.go index e842d75e9ac..03192e423f7 100644 --- a/services/context/base_test.go +++ b/services/context/base_test.go @@ -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()) diff --git a/services/context/context_test.go b/services/context/context_test.go index ddda6e5d4fb..f6e34619761 100644 --- a/services/context/context_test.go +++ b/services/context/context_test.go @@ -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"))) diff --git a/services/context/private.go b/services/context/private.go index 0adfaeafcd4..49b0d9b97c9 100644 --- a/services/context/private.go +++ b/services/context/private.go @@ -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 } diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go index 80739ca6c0e..41cf9eaaf8f 100644 --- a/services/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -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 } diff --git a/services/convert/convert.go b/services/convert/convert.go index ee3c87ebdf3..da9bfb5de08 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -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 } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e795d7ffb0c..cd7b94b7e2b 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -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 +} diff --git a/services/lfs/server.go b/services/lfs/server.go index 93387401369..ccca2297eea 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -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 { diff --git a/services/lfs/server_test.go b/services/lfs/server_test.go index f7312d2a6b9..8ccf777caff 100644 --- a/services/lfs/server_test.go +++ b/services/lfs/server_test.go @@ -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") + }) } diff --git a/services/markup/renderhelper_mention_test.go b/services/markup/renderhelper_mention_test.go index d4b34c2011f..54e51b613b6 100644 --- a/services/markup/renderhelper_mention_test.go +++ b/services/markup/renderhelper_mention_test.go @@ -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)) diff --git a/services/repository/push.go b/services/repository/push.go index a8b8187b633..ebfb116944a 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -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 } } diff --git a/templates/repo/settings/deploy_key_list.tmpl b/templates/repo/settings/deploy_key_list.tmpl new file mode 100644 index 00000000000..bfe9a85fa42 --- /dev/null +++ b/templates/repo/settings/deploy_key_list.tmpl @@ -0,0 +1,44 @@ +{{if .RepoDeployKeys}} +
+ {{range $key := .RepoDeployKeys}} +
+
+ {{$usedRecently := and $key.HasUsed $key.HasRecentActivity}} + + {{svg (Iif $key.IsKeyTypeToken "octicon-key-asterisk" "octicon-key") 32}} + +
+
+
{{$key.Name}}
+ {{if $key.Fingerprint}} +
{{$key.Fingerprint}}
+ {{end}} +
+ {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $key.CreatedUnix)}} + · + {{if $key.HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} + {{DateUtils.AbsoluteShort $key.UpdatedUnix}} + {{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}} +
+
+
+ {{if $key.IsKeyTypeToken}} + + {{end}} + +
+
+ {{end}} +
+{{else}} + {{ctx.Locale.Tr "repo.settings.no_deploy_keys"}} +{{end}} diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl index f5ce3fe3785..eda429820c1 100644 --- a/templates/repo/settings/deploy_keys.tmpl +++ b/templates/repo/settings/deploy_keys.tmpl @@ -1,92 +1,75 @@ {{template "repo/settings/layout_head" (dict "pageClass" "repository settings")}} -
-

- {{ctx.Locale.Tr "repo.settings.deploy_keys"}} -
+
+

+ {{ctx.Locale.Tr "repo.settings.deploy_keys"}} +
{{if not .DisableSSH}} - + {{else}} - - {{end}} -
-

-
-
-
-
- {{ctx.Locale.Tr "repo.settings.deploy_key_desc"}} -
-
- - -
-
- - -
-
-
- - - {{ctx.Locale.Tr "repo.settings.is_writable_info"}} -
-
- - -
-
- {{if .RepoDeployKeys}} -
- {{range $deployKey := .RepoDeployKeys}} -
-
- {{svg "octicon-key" 32}} -
-
-
{{$deployKey.Name}}
-
- {{$deployKey.Fingerprint}} -
-
- {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $deployKey.CreatedUnix)}} - - - {{svg "octicon-info"}} - {{if $deployKey.HasUsed}} - {{ctx.Locale.Tr "settings.last_used"}} - {{DateUtils.AbsoluteShort $deployKey.UpdatedUnix}} - {{else}} - {{ctx.Locale.Tr "settings.no_activity"}} - {{end}} - - - {{ctx.Locale.Tr "settings.can_read_info"}}{{if not $deployKey.IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}} -
-
-
- -
-
- {{end}} -
- {{else}} - {{ctx.Locale.Tr "repo.settings.no_deploy_keys"}} + {{end}} +
+

+
+
+
+
{{ctx.Locale.Tr "repo.settings.deploy_key_ssh_desc"}}
+
+ + +
+
+ + +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.is_writable_info"}} +
+
+ + +
+
+
+ +
+
+
{{ctx.Locale.Tr "repo.settings.deploy_key_token_desc"}}
+
+ + +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.is_writable_info"}} +
+
+ + +
+
+
+ + {{template "repo/settings/deploy_key_list" dict "RepoDeployKeys" .RepoDeployKeys}}
+
+ + {{template "repo/settings/layout_footer" .}} diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 86a4b046cf3..5de99641213 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -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", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index ecf79ee67d2..9526e7860d3 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -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" } diff --git a/tests/integration/api_keys_test.go b/tests/integration/api_keys_test.go index 40a3533890f..3da6294238f 100644 --- a/tests/integration/api_keys_test.go +++ b/tests/integration/api_keys_test.go @@ -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{})) +} diff --git a/tests/integration/api_private_serv_test.go b/tests/integration/api_private_serv_test.go index a8f1308448d..30abf963598 100644 --- a/tests/integration/api_private_serv_test.go +++ b/tests/integration/api_private_serv_test.go @@ -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) diff --git a/tests/integration/deploy_token_test.go b/tests/integration/deploy_token_test.go new file mode 100644 index 00000000000..ea81c7a1fe7 --- /dev/null +++ b/tests/integration/deploy_token_test.go @@ -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) + }) +} diff --git a/tests/integration/git_general_test.go b/tests/integration/git_general_test.go index da677cb7e05..43fbb78bfef 100644 --- a/tests/integration/git_general_test.go +++ b/tests/integration/git_general_test.go @@ -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`) }) } } diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 43dc3862d6e..63cdda827a0 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -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)) { diff --git a/web_src/js/modules/fomantic/base.ts b/web_src/js/modules/fomantic/base.ts index 8e955601800..594b7890293 100644 --- a/web_src/js/modules/fomantic/base.ts +++ b/web_src/js/modules/fomantic/base.ts @@ -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): ReturnType {