mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-30 12:07:20 +00:00
feat: add deploy tokens (#37306)
Deploy keys only work over SSH. A deploy token is their counterpart for HTTPS: a repository scoped credential, used as the password of a Git request, with read or read and write access. It covers Git operations and LFS, and can be regenerated in place. Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude Mythos <noreply@anthropic.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDoerNeedTwoFactorAuth(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)()
|
||||
|
||||
for _, doer := range []*user_model.User{nil, user_model.NewActionsUser(), user_model.NewDeployKeyUser()} {
|
||||
need, err := doerNeedTwoFactorAuth(t.Context(), doer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// appendPrivateInformation appends the owner and key type information to api.PublicKey
|
||||
func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *asymkey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
|
||||
func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *deploykey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
|
||||
apiKey.ReadOnly = key.Mode == perm.AccessModeRead
|
||||
if repository.ID == key.RepoID {
|
||||
apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode})
|
||||
@@ -78,14 +79,14 @@ func ListDeployKeys(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opts := asymkey_model.ListDeployKeysOptions{
|
||||
opts := deploykey_model.ListDeployKeysOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
KeyID: ctx.FormInt64("key_id"),
|
||||
Fingerprint: ctx.FormString("fingerprint"),
|
||||
}
|
||||
|
||||
keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts)
|
||||
keys, count, err := db.FindAndCount[deploykey_model.DeployKey](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -133,7 +134,7 @@ func GetDeployKey(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
key, err := deploykey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
@@ -160,13 +161,13 @@ func HandleCheckKeyStringError(ctx *context.APIContext, err error) {
|
||||
// HandleAddKeyError handle add key error
|
||||
func HandleAddKeyError(ctx *context.APIContext, err error) {
|
||||
switch {
|
||||
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "This key has already been added to this repository")
|
||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "Key content has been used as non-deploy key")
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "Key title has been used")
|
||||
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
case deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "A key with the same name already exists")
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -213,7 +214,7 @@ func CreateDeployKey(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
|
||||
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
if err != nil {
|
||||
HandleAddKeyError(ctx, err)
|
||||
return
|
||||
@@ -221,6 +222,49 @@ func CreateDeployKey(ctx *context.APIContext) {
|
||||
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
|
||||
}
|
||||
|
||||
// CreateDeployToken create a deploy token for a repository
|
||||
func CreateDeployToken(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/keys/tokens repository repoCreateDeployToken
|
||||
// ---
|
||||
// summary: Add a deploy token to a repository, it authenticates git over HTTPS
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateDeployKeyTokenOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/DeployKey"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm[*api.CreateDeployKeyTokenOption](ctx)
|
||||
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
|
||||
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
|
||||
if err != nil {
|
||||
HandleAddKeyError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
|
||||
}
|
||||
|
||||
// DeleteDeployKey delete deploy key for a repository
|
||||
func DeleteDeployKey(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey
|
||||
@@ -251,7 +295,8 @@ func DeleteDeployKey(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil {
|
||||
// a key that is already gone still leaves the caller with the state it asked for
|
||||
if _, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
if asymkey_model.IsErrKeyAccessDenied(err) {
|
||||
ctx.APIError(http.StatusForbidden, "You do not have access to this key")
|
||||
} else {
|
||||
|
||||
@@ -53,6 +53,9 @@ type swaggerParameterBodies struct {
|
||||
// in:body
|
||||
CreateKeyOption api.CreateKeyOption
|
||||
|
||||
// in:body
|
||||
CreateDeployKeyTokenOption api.CreateDeployKeyTokenOption
|
||||
|
||||
// in:body
|
||||
RenameUserOption api.RenameUserOption
|
||||
|
||||
|
||||
@@ -58,16 +58,13 @@ func Search(ctx *context.APIContext) {
|
||||
uid := ctx.FormInt64("uid")
|
||||
var users []*user_model.User
|
||||
var maxResults int64
|
||||
var err error
|
||||
|
||||
switch uid {
|
||||
case user_model.GhostUserID:
|
||||
maxResults = 1
|
||||
users = []*user_model.User{user_model.NewGhostUser()}
|
||||
case user_model.ActionsUserID:
|
||||
maxResults = 1
|
||||
users = []*user_model.User{user_model.NewActionsUser()}
|
||||
default:
|
||||
if uid < 0 {
|
||||
_, sysUser, _ := user_model.GetPossibleUserByID(ctx, uid)
|
||||
if sysUser != nil && sysUser.ID == uid {
|
||||
maxResults = 1
|
||||
users = []*user_model.User{sysUser}
|
||||
}
|
||||
} else {
|
||||
opts := user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
@@ -77,6 +74,7 @@ func Search(ctx *context.APIContext) {
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
opts.ApplyPublicOnly(ctx.PublicOnly)
|
||||
var err error
|
||||
users, maxResults, err = user_model.SearchUsers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]any{
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
|
||||
t.Run("HTML", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}}
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t))
|
||||
renderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
|
||||
respContent := w.Body.String()
|
||||
assert.Contains(t, respContent, `class="page-content status-page-500"`)
|
||||
@@ -36,7 +36,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
|
||||
t.Run("Plain", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := &http.Request{URL: &url.URL{}}
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
|
||||
req = req.WithContext(reqctx.NewRequestContextForTest(t))
|
||||
renderServiceUnavailable(w, req)
|
||||
assert.Equal(t, "Service Unavailable", w.Body.String())
|
||||
})
|
||||
|
||||
@@ -4,18 +4,13 @@
|
||||
package private
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/cachegroup"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/private"
|
||||
@@ -103,15 +98,11 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
|
||||
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ownerName := ctx.PathParam("owner")
|
||||
repoName := ctx.PathParam("repo")
|
||||
repo := loadRepository(ctx, ownerName, repoName)
|
||||
if ctx.Written() {
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
// now, repo can't be nil
|
||||
|
||||
repo := ctx.Repo.Repository
|
||||
// first, collect updates and sync branches
|
||||
updates := hookPostReceiveCollectPushUpdates(opts, repo)
|
||||
if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) {
|
||||
@@ -144,17 +135,7 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
||||
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
|
||||
// Handle Push Options
|
||||
if isPrivate.Has() || isTemplate.Has() {
|
||||
pusher, err := loadContextCacheUser(ctx, opts.UserID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to load pusher user: %v", err)
|
||||
return false
|
||||
}
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repo, pusher)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to load doer repo permission: %v", err)
|
||||
return false
|
||||
}
|
||||
if !perm.IsOwner() && !perm.IsAdmin() {
|
||||
if !ctx.Repo.Permission.IsAdmin() {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied")
|
||||
return false
|
||||
}
|
||||
@@ -171,13 +152,13 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
||||
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||
repo.IsPrivate = isPrivate.Value()
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("failed to update repo is_private: %v", err)
|
||||
}
|
||||
}
|
||||
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
|
||||
repo.IsTemplate = isTemplate.Value()
|
||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
|
||||
log.Error("failed to update repo is_template: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -244,10 +225,6 @@ func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *
|
||||
ctx.JSON(http.StatusOK, private.HookPostReceiveResult{Results: results})
|
||||
}
|
||||
|
||||
func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
|
||||
return cache.GetWithContextCache(ctx, cachegroup.User, id, user_model.GetUserByID)
|
||||
}
|
||||
|
||||
// hookPostReceiveHandlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
|
||||
func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool {
|
||||
if len(updates) == 0 {
|
||||
@@ -261,15 +238,9 @@ func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext,
|
||||
return false
|
||||
}
|
||||
|
||||
pusher, err := loadContextCacheUser(ctx, opts.UserID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to load pusher user %d: %v", opts.UserID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// FIXME: Maybe we need a `PullRequestStatusMerged` status for PRs that are merged, currently we use the previous status
|
||||
// here to keep it as before, that maybe PullRequestStatusMergeable
|
||||
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), pusher, pr.Status)
|
||||
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), ctx.Doer, pr.Status)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err)
|
||||
return false
|
||||
|
||||
@@ -25,13 +25,14 @@ func TestHandlePullRequestMerging(t *testing.T) {
|
||||
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
err = pull_model.ScheduleAutoMerge(t.Context(), user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
|
||||
|
||||
ctx, resp := contexttest.MockPrivateContext(t, "/")
|
||||
ctx.Doer = user2
|
||||
hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{
|
||||
PullRequestID: pr.ID,
|
||||
UserID: 2,
|
||||
|
||||
@@ -8,11 +8,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -27,29 +24,18 @@ import (
|
||||
|
||||
type preReceiveContext struct {
|
||||
*gitea_context.PrivateContext
|
||||
|
||||
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
|
||||
userPerm access_model.Permission
|
||||
deployKeyAccessMode perm_model.AccessMode
|
||||
|
||||
canCreatePullRequest bool
|
||||
checkedCanCreatePullRequest bool
|
||||
|
||||
protectedTags []*git_model.ProtectedTag
|
||||
gotProtectedTags bool
|
||||
|
||||
env []string
|
||||
|
||||
env []string
|
||||
opts *private.HookOptions
|
||||
|
||||
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||
canWriteCodeUnitCached *bool
|
||||
canCreatePullRequest *bool
|
||||
protectedTags []*git_model.ProtectedTag
|
||||
}
|
||||
|
||||
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||
if ctx.canWriteCodeUnitCached == nil {
|
||||
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
ctx.canWriteCodeUnitCached = &canWrite
|
||||
ctx.canWriteCodeUnitCached = new(ctx.Repo.Permission.CanWrite(unit.TypeCode))
|
||||
}
|
||||
return *ctx.canWriteCodeUnitCached
|
||||
}
|
||||
@@ -63,7 +49,7 @@ func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
|
||||
if !refFullName.IsBranch() {
|
||||
return false
|
||||
}
|
||||
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, refFullName.BranchName(), ctx.Doer)
|
||||
}
|
||||
|
||||
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||
@@ -80,11 +66,10 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||
|
||||
// CanCreatePullRequest returns true if pusher can create pull requests
|
||||
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
|
||||
if !ctx.checkedCanCreatePullRequest {
|
||||
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
|
||||
ctx.checkedCanCreatePullRequest = true
|
||||
if ctx.canCreatePullRequest == nil {
|
||||
ctx.canCreatePullRequest = new(ctx.Repo.Permission.CanRead(unit.TypePullRequests))
|
||||
}
|
||||
return ctx.canCreatePullRequest
|
||||
return *ctx.canCreatePullRequest
|
||||
}
|
||||
|
||||
// AssertCreatePullRequest returns true if can create pull requests
|
||||
@@ -102,6 +87,9 @@ func (ctx *preReceiveContext) AssertCreatePullRequest() bool {
|
||||
// HookPreReceive checks whether a individual commit is acceptable
|
||||
func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
opts := web.GetForm[*private.HookOptions](ctx)
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
|
||||
ourCtx := &preReceiveContext{
|
||||
PrivateContext: ctx,
|
||||
@@ -109,10 +97,6 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
if !ourCtx.loadPusherAndPermission() {
|
||||
return // if error occurs, loadPusherAndPermission had written the error response
|
||||
}
|
||||
|
||||
// Iterate across the provided old commit IDs
|
||||
for i := range opts.OldCommitIDs {
|
||||
oldCommitID := opts.OldCommitIDs[i]
|
||||
@@ -236,7 +220,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
|
||||
// 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
|
||||
var canPush bool
|
||||
if ctx.opts.DeployKeyID != 0 {
|
||||
if ctx.opts.UserID == user_model.DeployKeyUserID {
|
||||
// This flag is only ever true if protectBranch.CanForcePush is true
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
|
||||
@@ -245,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
} else {
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
|
||||
} else {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +280,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
|
||||
// Now check if the user is allowed to merge PRs for this repository
|
||||
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
|
||||
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
|
||||
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.Repo.Permission, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Error calculating if allowed to merge: %v", err)
|
||||
return
|
||||
@@ -308,7 +292,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
|
||||
// If we can bypass branch protection we can ignore status checks, reviews and protected files
|
||||
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
|
||||
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.Doer, ctx.Repo.Permission.IsAdmin()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -337,14 +321,14 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||
|
||||
tagName := refFullName.TagName()
|
||||
|
||||
if !ctx.gotProtectedTags {
|
||||
if ctx.protectedTags == nil {
|
||||
var err error
|
||||
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get protected tags: %v", err)
|
||||
return
|
||||
}
|
||||
ctx.gotProtectedTags = true
|
||||
ctx.protectedTags = util.SliceNilAsEmpty(ctx.protectedTags)
|
||||
}
|
||||
|
||||
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
|
||||
@@ -399,45 +383,3 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
|
||||
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||
if ctx.opts.UserID == user_model.ActionsUserID {
|
||||
taskID := ctx.opts.ActionsTaskID
|
||||
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
|
||||
if taskID == 0 {
|
||||
ctx.PrivateUserErrorf(http.StatusInternalServerError, "ActionsUser with task ID 0")
|
||||
return false
|
||||
}
|
||||
|
||||
userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err)
|
||||
return false
|
||||
}
|
||||
ctx.userPerm = userPerm
|
||||
} else {
|
||||
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
|
||||
return false
|
||||
}
|
||||
ctx.user = user
|
||||
userPerm, err := access_model.GetDoerRepoPermission(ctx, ctx.Repo.Repository, user)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err)
|
||||
return false
|
||||
}
|
||||
ctx.userPerm = userPerm
|
||||
}
|
||||
|
||||
if ctx.opts.DeployKeyID != 0 {
|
||||
deployKey, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.opts.DeployKeyID)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err)
|
||||
return false
|
||||
}
|
||||
ctx.deployKeyAccessMode = deployKey.Mode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
|
||||
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
// together with a protected branch or a tag to escalate into full repository write.
|
||||
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
@@ -45,16 +44,12 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||
|
||||
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||
maintainer := baseRepo.Owner
|
||||
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{
|
||||
PrivateContext: mockCtx,
|
||||
user: maintainer,
|
||||
userPerm: headPerm,
|
||||
}
|
||||
ctx := &preReceiveContext{PrivateContext: mockCtx}
|
||||
ctx.SetPathParam("owner", headRepo.OwnerName)
|
||||
ctx.SetPathParam("repo", headRepo.Name)
|
||||
RepoAssignment(ctx.PrivateContext)
|
||||
loadContextDoerPermission(ctx.PrivateContext, baseRepo.OwnerID, "")
|
||||
|
||||
// The granted branch must be writable...
|
||||
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||
|
||||
@@ -23,8 +23,17 @@ func HookProcReceive(ctx *gitea_context.PrivateContext) {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
|
||||
return
|
||||
}
|
||||
|
||||
results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, opts)
|
||||
results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, &agit.ProcReceiveOptions{
|
||||
OldCommitIDs: opts.OldCommitIDs,
|
||||
NewCommitIDs: opts.NewCommitIDs,
|
||||
RefFullNames: opts.RefFullNames,
|
||||
GitPushOptions: opts.GitPushOptions,
|
||||
Doer: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, issues_model.ErrMustCollaborator) {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "You must be a collaborator to create pull request.")
|
||||
|
||||
@@ -80,7 +80,7 @@ func Routes() *web.Router {
|
||||
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
|
||||
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
|
||||
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
|
||||
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), bind(private.HookOptions{}), HookPostReceive)
|
||||
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookPostReceive)
|
||||
r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
|
||||
r.Get("/serv/none/{keyid}", ServNoCommand)
|
||||
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package private
|
||||
|
||||
import (
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
gitea_context "gitea.dev/services/context"
|
||||
)
|
||||
@@ -27,10 +29,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) {
|
||||
ctx.PrivateInternalErrorf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
|
||||
return
|
||||
}
|
||||
ctx.Repo = &gitea_context.Repository{
|
||||
Repository: repo,
|
||||
GitRepo: gitRepo,
|
||||
}
|
||||
ctx.Repo = &gitea_context.Repository{Repository: repo, GitRepo: gitRepo}
|
||||
}
|
||||
|
||||
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository {
|
||||
@@ -44,3 +43,18 @@ func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName strin
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func loadContextDoerPermission(ctx *gitea_context.PrivateContext, userID int64, extDoerData string) bool {
|
||||
doer, err := user.GetDoerUser(ctx, userID, extDoerData)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Failed to get user: %d, error: %v", userID, err)
|
||||
return false
|
||||
}
|
||||
ctx.Doer = doer
|
||||
ctx.Repo.Permission, err = access.GetDoerRepoPermission(ctx, ctx.Repo.Repository, doer)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Failed to get permission for user: %d, error: %v", userID, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/modules/timeutil"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
@@ -20,17 +20,16 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
|
||||
return
|
||||
}
|
||||
|
||||
deployKey, err := asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
|
||||
deployKey, err := deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrDeployKeyNotExist(err) {
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.PlainText(http.StatusOK, "success")
|
||||
return
|
||||
}
|
||||
ctx.PrivateInternalErrorf("%v", err)
|
||||
return
|
||||
}
|
||||
deployKey.UpdatedUnix = timeutil.TimeStampNow()
|
||||
if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil {
|
||||
if err = deploykey_model.UpdateDeployKeyLastUsed(ctx, deployKey.ID); err != nil {
|
||||
ctx.PrivateInternalErrorf("%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
+35
-54
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -73,9 +74,9 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
|
||||
// Set the basic parts of the results to return
|
||||
results := private.ServCommandResults{
|
||||
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
|
||||
RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
|
||||
KeyID: keyID,
|
||||
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
|
||||
RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
|
||||
PublicKeyID: keyID,
|
||||
}
|
||||
repoLogName := reqOwnerName + "/" + reqRepoName
|
||||
|
||||
@@ -184,40 +185,25 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
ctx.PrivateInternalErrorf("Unable to get key: %d, error: %v", keyID, err)
|
||||
return
|
||||
}
|
||||
results.KeyName = key.Name
|
||||
results.KeyID = key.ID
|
||||
results.UserID = key.OwnerID
|
||||
results.PublicKeyID = key.ID
|
||||
|
||||
// Deploy Keys have ownerID set to 0 therefore we can't use the owner
|
||||
// So now we need to check if the key is a deploy key
|
||||
// We'll keep hold of the deploy key here for permissions checking
|
||||
var deployKey *asymkey_model.DeployKey
|
||||
var deployKey *deploykey_model.DeployKey
|
||||
var user *user_model.User
|
||||
if key.Type == asymkey_model.KeyTypeDeploy {
|
||||
if repo == nil {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName)
|
||||
return
|
||||
}
|
||||
deployKey, err = asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
|
||||
deployKey, err = deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
|
||||
if err != nil {
|
||||
if asymkey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy-key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
ctx.PrivateInternalErrorf("Unable to get deploy for public (deploy) key %d for %s, error: %v", key.ID, repoLogName, err)
|
||||
return
|
||||
}
|
||||
results.DeployKeyID = deployKey.ID
|
||||
results.KeyName = deployKey.Name
|
||||
|
||||
// FIXME: Deploy keys aren't really the owner of the repo pushing changes
|
||||
// however we don't have good way of representing deploy keys in hook.go
|
||||
// so for now use the owner of the repository
|
||||
results.UserName = results.OwnerName
|
||||
results.UserID = repo.OwnerID
|
||||
if !repo.Owner.KeepEmailPrivate {
|
||||
results.UserEmail = repo.Owner.Email
|
||||
}
|
||||
user = user_model.NewDeployKeyUserWithKeyID(deployKey.ID)
|
||||
} else {
|
||||
// Get the user represented by the Key
|
||||
user, err = user_model.GetUserByID(ctx, key.OwnerID)
|
||||
@@ -229,16 +215,19 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
ctx.PrivateInternalErrorf("Unable to get key owner %d for public key %d:%s, error: %v", key.OwnerID, key.ID, key.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsActive || user.ProhibitLogin {
|
||||
ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
results.UserName = user.Name
|
||||
if !user.KeepEmailPrivate {
|
||||
results.UserEmail = user.Email
|
||||
}
|
||||
results.UserID = user.ID
|
||||
results.UserName = user.Name
|
||||
if !user.KeepEmailPrivate {
|
||||
results.UserEmail = user.Email
|
||||
}
|
||||
if user.ExtDoerData != nil {
|
||||
results.UserExtDoerData = user.ExtDoerData.EncodeToString()
|
||||
}
|
||||
|
||||
// Don't allow pushing if the repo is archived
|
||||
@@ -252,37 +241,29 @@ func ServCommand(ctx *context.PrivateContext) {
|
||||
(mode > perm.AccessModeRead ||
|
||||
repo.IsPrivate ||
|
||||
owner.Visibility.IsPrivate() ||
|
||||
(user != nil && user.IsRestricted) || // user will be nil if the key is a deploy key
|
||||
user.IsRestricted ||
|
||||
setting.Service.RequireSignInViewStrict) {
|
||||
if key.Type == asymkey_model.KeyTypeDeploy {
|
||||
if deployKey == nil || deployKey.Mode < mode {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
|
||||
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
|
||||
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
|
||||
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
|
||||
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
|
||||
mode = perm.AccessModeRead
|
||||
}
|
||||
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
|
||||
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
|
||||
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
|
||||
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
|
||||
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
|
||||
mode = perm.AccessModeRead
|
||||
}
|
||||
|
||||
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
|
||||
return
|
||||
}
|
||||
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
|
||||
return
|
||||
}
|
||||
|
||||
userMode := userPerm.UnitAccessMode(unitType)
|
||||
if userMode < mode {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User %d with key %d:%s has no %q permission for %s", key.OwnerID, key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
userMode := userPerm.UnitAccessMode(unitType)
|
||||
if userMode < mode {
|
||||
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User key %d:%s has no %q permission for %s", key.ID, key.Name, modeString, repoLogName)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// We already know we aren't using a deploy key
|
||||
if repo == nil {
|
||||
if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
|
||||
ctx.PrivateUserErrorf(http.StatusForbidden, "Push to create is not enabled for organizations.")
|
||||
|
||||
@@ -212,7 +212,7 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil)
|
||||
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(t, resp, req), nil, nil)
|
||||
ctx.Repo.Repository = &repo_model.Repository{
|
||||
OwnerName: "user1",
|
||||
Name: "repo1",
|
||||
|
||||
@@ -163,7 +163,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && !ctx.Doer.IsGiteaActions() {
|
||||
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && ctx.Doer.IsIndividual() {
|
||||
_, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
|
||||
if err == nil {
|
||||
// TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
|
||||
@@ -252,7 +252,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
|
||||
var environ []string
|
||||
if !isPull {
|
||||
// if not "pull", then must be "push", and doer must exist
|
||||
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -17,23 +19,20 @@ import (
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
// DeployKeys render the deploy-keys list of a repository page
|
||||
func DeployKeys(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets")
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
|
||||
ctx.Data["PageIsSettingsKeys"] = true
|
||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
||||
|
||||
keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
|
||||
keys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
|
||||
if err != nil {
|
||||
ctx.ServerError("ListDeployKeys", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["RepoDeployKeys"] = keys
|
||||
|
||||
ctx.HTML(http.StatusOK, tplDeployKeys)
|
||||
}
|
||||
|
||||
// DeployKeysPost response for adding a deploy-key of a repository
|
||||
func DeployKeysPost(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.AddKeyForm](ctx)
|
||||
if form == nil {
|
||||
@@ -54,16 +53,14 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
|
||||
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
|
||||
if err != nil {
|
||||
switch {
|
||||
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content")
|
||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content")
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
case asymkey_model.IsErrKeyNameAlreadyUsed(err), deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
default:
|
||||
ctx.ServerError("AddDeployKey", err)
|
||||
@@ -75,12 +72,50 @@ func DeployKeysPost(ctx *context.Context) {
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
// DeleteDeployKey response for deleting a deploy-key
|
||||
func DeleteDeployKey(ctx *context.Context) {
|
||||
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil {
|
||||
key, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id"))
|
||||
if err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) { // a key that is already gone leaves the caller with the state it asked for
|
||||
ctx.ServerError("DeleteDeployKey", err)
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
|
||||
return
|
||||
}
|
||||
if key != nil {
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success", key.Name))
|
||||
}
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
func DeployKeyGenerateToken(ctx *context.Context) {
|
||||
form := context.GetFetchActionForm[*forms.AddDeployTokenForm](ctx)
|
||||
if form == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
|
||||
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNameAlreadyUsed(err) {
|
||||
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
|
||||
} else {
|
||||
ctx.ServerError("AddDeployToken", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.generate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
func DeployKeyRegenerateToken(ctx *context.Context) {
|
||||
key, err := deploykey_model.RegenerateDeployKeyToken(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id"))
|
||||
if err != nil {
|
||||
if deploykey_model.IsErrDeployKeyNotExist(err) {
|
||||
ctx.JSONErrorNotFound()
|
||||
} else {
|
||||
ctx.ServerError("RegenerateDeployToken", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.regenerate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
deploykey_model "gitea.dev/models/deploykey"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
@@ -38,7 +38,7 @@ func TestAddDeployKey(t *testing.T) {
|
||||
contexttest.LoadRepo(t, ctx, 2)
|
||||
DeployKeysPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
|
||||
})
|
||||
t.Run("ReadWrite", func(t *testing.T) {
|
||||
const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n"
|
||||
@@ -47,7 +47,7 @@ func TestAddDeployKey(t *testing.T) {
|
||||
contexttest.LoadRepo(t, ctx, 2)
|
||||
DeployKeysPost(ctx)
|
||||
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
|
||||
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
|
||||
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user