Refactor Actions Token Access (#35688)

* use a single function to do Action Tokens Permission checks
* allows easier customization
* add basic tests
* lfs file locks should work now

---------

Signed-off-by: ChristopherHX <christopher.homberger@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ChristopherHX 2025-10-22 13:12:31 +02:00 committed by GitHub
parent a9f2ea720b
commit 08b9776970
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 218 additions and 168 deletions

View File

@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -42,30 +41,6 @@ func (err ErrLFSLockNotExist) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// ErrLFSUnauthorizedAction represents a "LFSUnauthorizedAction" kind of error.
type ErrLFSUnauthorizedAction struct {
RepoID int64
UserName string
Mode perm.AccessMode
}
// IsErrLFSUnauthorizedAction checks if an error is a ErrLFSUnauthorizedAction.
func IsErrLFSUnauthorizedAction(err error) bool {
_, ok := err.(ErrLFSUnauthorizedAction)
return ok
}
func (err ErrLFSUnauthorizedAction) Error() string {
if err.Mode == perm.AccessModeWrite {
return fmt.Sprintf("User %s doesn't have write access for lfs lock [rid: %d]", err.UserName, err.RepoID)
}
return fmt.Sprintf("User %s doesn't have read access for lfs lock [rid: %d]", err.UserName, err.RepoID)
}
func (err ErrLFSUnauthorizedAction) Unwrap() error {
return util.ErrPermissionDenied
}
// ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error. // ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error.
type ErrLFSLockAlreadyExist struct { type ErrLFSLockAlreadyExist struct {
RepoID int64 RepoID int64
@ -93,12 +68,6 @@ type ErrLFSFileLocked struct {
UserName string UserName string
} }
// IsErrLFSFileLocked checks if an error is a ErrLFSFileLocked.
func IsErrLFSFileLocked(err error) bool {
_, ok := err.(ErrLFSFileLocked)
return ok
}
func (err ErrLFSFileLocked) Error() string { func (err ErrLFSFileLocked) Error() string {
return fmt.Sprintf("File is lfs locked [repo: %d, locked by: %s, path: %s]", err.RepoID, err.UserName, err.Path) return fmt.Sprintf("File is lfs locked [repo: %d, locked by: %s, path: %s]", err.RepoID, err.UserName, err.Path)
} }

View File

@ -11,10 +11,7 @@ import (
"time" "time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -71,10 +68,6 @@ func (l *LFSLock) LoadOwner(ctx context.Context) error {
// CreateLFSLock creates a new lock. // CreateLFSLock creates a new lock.
func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLock) (*LFSLock, error) { func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLock) (*LFSLock, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) { return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) {
if err := CheckLFSAccessForRepo(ctx, lock.OwnerID, repo, perm.AccessModeWrite); err != nil {
return nil, err
}
lock.Path = util.PathJoinRel(lock.Path) lock.Path = util.PathJoinRel(lock.Path)
lock.RepoID = repo.ID lock.RepoID = repo.ID
@ -165,10 +158,6 @@ func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repositor
return nil, err return nil, err
} }
if err := CheckLFSAccessForRepo(ctx, u.ID, repo, perm.AccessModeWrite); err != nil {
return nil, err
}
if !force && u.ID != lock.OwnerID { if !force && u.ID != lock.OwnerID {
return nil, errors.New("user doesn't own lock and force flag is not set") return nil, errors.New("user doesn't own lock and force flag is not set")
} }
@ -180,22 +169,3 @@ func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repositor
return lock, nil return lock, nil
}) })
} }
// CheckLFSAccessForRepo check needed access mode base on action
func CheckLFSAccessForRepo(ctx context.Context, ownerID int64, repo *repo_model.Repository, mode perm.AccessMode) error {
if ownerID == 0 {
return ErrLFSUnauthorizedAction{repo.ID, "undefined", mode}
}
u, err := user_model.GetUserByID(ctx, ownerID)
if err != nil {
return err
}
perm, err := access_model.GetUserRepoPermission(ctx, repo, u)
if err != nil {
return err
}
if !perm.CanAccess(mode, unit.TypeCode) {
return ErrLFSUnauthorizedAction{repo.ID, u.DisplayName(), mode}
}
return nil
}

View File

@ -5,9 +5,11 @@ package access
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"slices" "slices"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
perm_model "code.gitea.io/gitea/models/perm" perm_model "code.gitea.io/gitea/models/perm"
@ -253,6 +255,34 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) {
} }
} }
// GetActionsUserRepoPermission returns the actions user permissions to the repository
func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) {
if actionsUser.ID != user_model.ActionsUserID {
return perm, errors.New("api GetActionsUserRepoPermission can only be called by the actions user")
}
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
return perm, err
}
if task.RepoID != repo.ID {
// FIXME allow public repo read access if tokenless pull is enabled
return perm, nil
}
var accessMode perm_model.AccessMode
if task.IsForkPullRequest {
accessMode = perm_model.AccessModeRead
} else {
accessMode = perm_model.AccessModeWrite
}
if err := repo.LoadUnits(ctx); err != nil {
return perm, err
}
perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode)
return perm, nil
}
// GetUserRepoPermission returns the user permissions to the repository // GetUserRepoPermission returns the user permissions to the repository
func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) { func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) {
defer func() { defer func() {

View File

@ -249,8 +249,13 @@ func (u *User) MaxCreationLimit() int {
} }
// CanCreateRepoIn checks whether the doer(u) can create a repository in the owner // CanCreateRepoIn checks whether the doer(u) can create a repository in the owner
// NOTE: functions calling this assume a failure due to repository count limit; it ONLY checks the repo number LIMIT, if new checks are added, those functions should be revised // NOTE: functions calling this assume a failure due to repository count limit, or the owner is not a real user.
// It ONLY checks the repo number LIMIT or whether owner user is real. If new checks are added, those functions should be revised.
// TODO: the callers can only return ErrReachLimitOfRepo, need to fine tune to support other error types in the future.
func (u *User) CanCreateRepoIn(owner *User) bool { func (u *User) CanCreateRepoIn(owner *User) bool {
if u.ID <= 0 || owner.ID <= 0 {
return false // fake user like Ghost or Actions user
}
if u.IsAdmin { if u.IsAdmin {
return true return true
} }

View File

@ -57,7 +57,6 @@ func NewActionsUser() *User {
KeepEmailPrivate: true, KeepEmailPrivate: true,
LoginName: ActionsUserName, LoginName: ActionsUserName,
Type: UserTypeBot, Type: UserTypeBot,
AllowCreateOrganization: true,
Visibility: structs.VisibleTypePublic, Visibility: structs.VisibleTypePublic,
} }
} }

View File

@ -648,33 +648,36 @@ func TestGetInactiveUsers(t *testing.T) {
func TestCanCreateRepo(t *testing.T) { func TestCanCreateRepo(t *testing.T) {
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)() defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)()
const noLimit = -1 const noLimit = -1
doerNormal := &user_model.User{} doerActions := user_model.NewActionsUser()
doerAdmin := &user_model.User{IsAdmin: true} doerNormal := &user_model.User{ID: 2}
doerAdmin := &user_model.User{ID: 1, IsAdmin: true}
t.Run("NoGlobalLimit", func(t *testing.T) { t.Run("NoGlobalLimit", func(t *testing.T) {
setting.Repository.MaxCreationLimit = noLimit setting.Repository.MaxCreationLimit = noLimit
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0}))
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100}))
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit}))
assert.False(t, doerActions.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit}))
assert.False(t, doerAdmin.CanCreateRepoIn(doerActions))
}) })
t.Run("GlobalLimit50", func(t *testing.T) { t.Run("GlobalLimit50", func(t *testing.T) {
setting.Repository.MaxCreationLimit = 50 setting.Repository.MaxCreationLimit = 50
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit}))
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit
assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0}))
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100}))
assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: 100})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: noLimit})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100}))
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: 100})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100}))
}) })
} }

View File

@ -70,7 +70,6 @@ import (
"net/http" "net/http"
"strings" "strings"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
@ -190,27 +189,11 @@ func repoAssignment() func(ctx *context.APIContext) {
if ctx.Doer != nil && ctx.Doer.ID == user_model.ActionsUserID { if ctx.Doer != nil && ctx.Doer.ID == user_model.ActionsUserID {
taskID := ctx.Data["ActionsTaskID"].(int64) taskID := ctx.Data["ActionsTaskID"].(int64)
task, err := actions_model.GetTaskByID(ctx, taskID) ctx.Repo.Permission, err = access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
if task.RepoID != repo.ID {
ctx.APIErrorNotFound()
return
}
if task.IsForkPullRequest {
ctx.Repo.Permission.AccessMode = perm.AccessModeRead
} else {
ctx.Repo.Permission.AccessMode = perm.AccessModeWrite
}
if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Repo.Permission.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.Repo.Permission.AccessMode)
} else { } else {
needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer) needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
if err != nil { if err != nil {

View File

@ -28,6 +28,7 @@ import (
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/routers/api/v1/utils"
@ -270,6 +271,8 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre
db.IsErrNamePatternNotAllowed(err) || db.IsErrNamePatternNotAllowed(err) ||
label.IsErrTemplateLoad(err) { label.IsErrTemplateLoad(err) {
ctx.APIError(http.StatusUnprocessableEntity, err) ctx.APIError(http.StatusUnprocessableEntity, err)
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err)
} else { } else {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
} }

View File

@ -18,7 +18,6 @@ import (
"sync" "sync"
"time" "time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
@ -190,29 +189,17 @@ func httpBase(ctx *context.Context) *serviceHandler {
if ctx.Data["IsActionsToken"] == true { if ctx.Data["IsActionsToken"] == true {
taskID := ctx.Data["ActionsTaskID"].(int64) taskID := ctx.Data["ActionsTaskID"].(int64)
task, err := actions_model.GetTaskByID(ctx, taskID) p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
if err != nil { if err != nil {
ctx.ServerError("GetTaskByID", err) ctx.ServerError("GetUserRepoPermission", err)
return nil
}
if task.RepoID != repo.ID {
ctx.PlainText(http.StatusForbidden, "User permission denied")
return nil return nil
} }
if task.IsForkPullRequest { if !p.CanAccess(accessMode, unitType) {
if accessMode > perm.AccessModeRead { ctx.PlainText(http.StatusNotFound, "Repository not found")
ctx.PlainText(http.StatusForbidden, "User permission denied")
return nil return nil
} }
environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeRead)) environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType)))
} else {
if accessMode > perm.AccessModeWrite {
ctx.PlainText(http.StatusForbidden, "User permission denied")
return nil
}
environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeWrite))
}
} else { } else {
p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil { if err != nil {

View File

@ -771,7 +771,7 @@ func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {
// ToLFSLock convert a LFSLock to api.LFSLock // ToLFSLock convert a LFSLock to api.LFSLock
func ToLFSLock(ctx context.Context, l *git_model.LFSLock) *api.LFSLock { func ToLFSLock(ctx context.Context, l *git_model.LFSLock) *api.LFSLock {
u, err := user_model.GetUserByID(ctx, l.OwnerID) u, err := user_model.GetPossibleUserByID(ctx, l.OwnerID)
if err != nil { if err != nil {
return nil return nil
} }

View File

@ -187,13 +187,6 @@ func PostLockHandler(ctx *context.Context) {
}) })
return return
} }
if git_model.IsErrLFSUnauthorizedAction(err) {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
Message: "You must have push access to create locks : " + err.Error(),
})
return
}
log.Error("Unable to CreateLFSLock in repository %-v at %s for user %-v: Error: %v", repository, req.Path, ctx.Doer, err) log.Error("Unable to CreateLFSLock in repository %-v at %s for user %-v: Error: %v", repository, req.Path, ctx.Doer, err)
ctx.JSON(http.StatusInternalServerError, api.LFSLockError{ ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
Message: "internal server error : Internal Server Error", Message: "internal server error : Internal Server Error",
@ -317,13 +310,6 @@ func UnLockHandler(ctx *context.Context) {
lock, err := git_model.DeleteLFSLockByID(ctx, ctx.PathParamInt64("lid"), repository, ctx.Doer, req.Force) lock, err := git_model.DeleteLFSLockByID(ctx, ctx.PathParamInt64("lid"), repository, ctx.Doer, req.Force)
if err != nil { if err != nil {
if git_model.IsErrLFSUnauthorizedAction(err) {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
Message: "You must have push access to delete locks : " + err.Error(),
})
return
}
log.Error("Unable to DeleteLFSLockByID[%d] by user %-v with force %t: Error: %v", ctx.PathParamInt64("lid"), ctx.Doer, req.Force, err) log.Error("Unable to DeleteLFSLockByID[%d] by user %-v with force %t: Error: %v", ctx.PathParamInt64("lid"), ctx.Doer, req.Force, err)
ctx.JSON(http.StatusInternalServerError, api.LFSLockError{ ctx.JSON(http.StatusInternalServerError, api.LFSLockError{
Message: "unable to delete lock : Internal Server Error", Message: "unable to delete lock : Internal Server Error",

View File

@ -20,7 +20,6 @@ import (
"strings" "strings"
"time" "time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
perm_model "code.gitea.io/gitea/models/perm" perm_model "code.gitea.io/gitea/models/perm"
@ -549,33 +548,31 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
if ctx.Data["IsActionsToken"] == true { if ctx.Data["IsActionsToken"] == true {
taskID := ctx.Data["ActionsTaskID"].(int64) taskID := ctx.Data["ActionsTaskID"].(int64)
task, err := actions_model.GetTaskByID(ctx, taskID) perm, err := access_model.GetActionsUserRepoPermission(ctx, repository, ctx.Doer, taskID)
if err != nil { if err != nil {
log.Error("Unable to GetTaskByID for task[%d] Error: %v", taskID, err) log.Error("Unable to GetActionsUserRepoPermission for task[%d] Error: %v", taskID, err)
return false return false
} }
if task.RepoID != repository.ID { return perm.CanAccess(accessMode, unit.TypeCode)
return false
} }
if task.IsForkPullRequest { // it works for both anonymous request and signed-in user, then perm.CanAccess will do the permission check
return accessMode <= perm_model.AccessModeRead
}
return accessMode <= perm_model.AccessModeWrite
}
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess
perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
if err != nil { if err != nil {
log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.Doer, repository, err) log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.Doer, repository, err)
return false return false
} }
canRead := perm.CanAccess(accessMode, unit.TypeCode) canAccess := perm.CanAccess(accessMode, unit.TypeCode)
if canRead && (!requireSigned || ctx.IsSigned) { // if it doesn't require sign-in and anonymous user has access, return true
// if the user is already signed in (for example: by session auth method), and the doer can access, return true
if canAccess && (!requireSigned || ctx.IsSigned) {
return true return true
} }
// now, either sign-in is required or the ctx.Doer cannot access, check the LFS token
// however, "ctx.Doer exists but cannot access then check LFS token" should not really happen:
// * why a request can be sent with both valid user session and valid LFS token then use LFS token to access?
user, err := parseToken(ctx, authorization, repository, accessMode) user, err := parseToken(ctx, authorization, repository, accessMode)
if err != nil { if err != nil {
// Most of these are Warn level - the true internal server errors are logged in parseToken already // Most of these are Warn level - the true internal server errors are logged in parseToken already

View File

@ -0,0 +1,117 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"net/http"
"net/url"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsJobTokenAccess(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
t.Run("Write Access", testActionsJobTokenAccess(u, false))
t.Run("Read Access", testActionsJobTokenAccess(u, true))
})
}
func testActionsJobTokenAccess(u *url.URL, isFork bool) func(t *testing.T) {
return func(t *testing.T) {
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
require.NoError(t, task.GenerateToken())
task.Status = actions_model.StatusRunning
task.IsForkPullRequest = isFork
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
require.NoError(t, err)
session := emptyTestSession(t)
context := APITestContext{
Session: session,
Token: task.Token,
Username: "user5",
Reponame: "repo4",
}
dstPath := t.TempDir()
u.Path = context.GitPath()
u.User = url.UserPassword("gitea-actions", task.Token)
t.Run("Git Clone", doGitClone(dstPath, u))
t.Run("API Get Repository", doAPIGetRepository(context, func(t *testing.T, r structs.Repository) {
require.Equal(t, "repo4", r.Name)
require.Equal(t, "user5", r.Owner.UserName)
}))
context.ExpectedCode = util.Iif(isFork, http.StatusForbidden, http.StatusCreated)
t.Run("API Create File", doAPICreateFile(context, "test.txt", &structs.CreateFileOptions{
FileOptions: structs.FileOptions{
NewBranchName: "new-branch",
Message: "Create File",
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`This is a test file created using job token.`)),
}))
context.ExpectedCode = http.StatusForbidden
t.Run("Fail to Create Repository", doAPICreateRepository(context, true))
context.ExpectedCode = http.StatusForbidden
t.Run("Fail to Delete Repository", doAPIDeleteRepository(context))
t.Run("Fail to Create Organization", doAPICreateOrganization(context, &structs.CreateOrgOption{
UserName: "actions",
FullName: "Gitea Actions",
}))
}
}
func TestActionsJobTokenAccessLFS(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
httpContext := NewAPITestContext(t, "user2", "repo-lfs-test", auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
t.Run("Create Repository", doAPICreateRepository(httpContext, false, func(t *testing.T, repository structs.Repository) {
task := &actions_model.ActionTask{}
require.NoError(t, task.GenerateToken())
task.Status = actions_model.StatusRunning
task.IsForkPullRequest = false
task.RepoID = repository.ID
err := db.Insert(t.Context(), task)
require.NoError(t, err)
session := emptyTestSession(t)
httpContext := APITestContext{
Session: session,
Token: task.Token,
Username: "user2",
Reponame: "repo-lfs-test",
}
u.Path = httpContext.GitPath()
dstPath := t.TempDir()
u.Path = httpContext.GitPath()
u.User = url.UserPassword("gitea-actions", task.Token)
t.Run("Clone", doGitClone(dstPath, u))
dstPath2 := t.TempDir()
t.Run("Partial Clone", doPartialGitClone(dstPath2, u))
lfs := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall)[0]
reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo-lfs-test/media/"+lfs).AddTokenAuth(task.Token)
respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK)
assert.Equal(t, testFileSizeSmall, respLFS.Length)
}))
})
}

View File

@ -9,7 +9,6 @@ import (
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -25,8 +24,9 @@ func TestAPIGetRawFileOrLFS(t *testing.T) {
// Test with LFS // Test with LFS
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
createLFSTestRepository(t, "repo-lfs-test")
httpContext := NewAPITestContext(t, "user2", "repo-lfs-test", auth_model.AccessTokenScopeWriteRepository) httpContext := NewAPITestContext(t, "user2", "repo-lfs-test", auth_model.AccessTokenScopeWriteRepository)
doAPICreateRepository(httpContext, false, func(t *testing.T, repository api.Repository) { t.Run("repo-lfs-test", func(t *testing.T) {
u.Path = httpContext.GitPath() u.Path = httpContext.GitPath()
dstPath := t.TempDir() dstPath := t.TempDir()
@ -41,7 +41,7 @@ func TestAPIGetRawFileOrLFS(t *testing.T) {
lfs := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall)[0] lfs := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall)[0]
reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/media/"+lfs) reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo-lfs-test/media/"+lfs).AddTokenAuth(httpContext.Token)
respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK) respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK)
assert.Equal(t, testFileSizeSmall, respLFS.Length) assert.Equal(t, testFileSizeSmall, respLFS.Length)
}) })

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestAPILFSNotStarted(t *testing.T) { func TestAPILFSNotStarted(t *testing.T) {
@ -59,12 +60,12 @@ func TestAPILFSMediaType(t *testing.T) {
MakeRequest(t, req, http.StatusUnsupportedMediaType) MakeRequest(t, req, http.StatusUnsupportedMediaType)
} }
func createLFSTestRepository(t *testing.T, name string) *repo_model.Repository { func createLFSTestRepository(t *testing.T, repoName string) *repo_model.Repository {
ctx := NewAPITestContext(t, "user2", "lfs-"+name+"-repo", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) ctx := NewAPITestContext(t, "user2", repoName, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
t.Run("CreateRepo", doAPICreateRepository(ctx, false)) t.Run("CreateRepo", doAPICreateRepository(ctx, false))
repo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", "lfs-"+name+"-repo") repo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", repoName)
assert.NoError(t, err) require.NoError(t, err)
return repo return repo
} }
@ -74,7 +75,7 @@ func TestAPILFSBatch(t *testing.T) {
setting.LFS.StartServer = true setting.LFS.StartServer = true
repo := createLFSTestRepository(t, "batch") repo := createLFSTestRepository(t, "lfs-batch-repo")
content := []byte("dummy1") content := []byte("dummy1")
oid := storeObjectInRepo(t, repo.ID, &content) oid := storeObjectInRepo(t, repo.ID, &content)
@ -253,7 +254,7 @@ func TestAPILFSBatch(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, exist) assert.True(t, exist)
repo2 := createLFSTestRepository(t, "batch2") repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
content := []byte("dummy0") content := []byte("dummy0")
storeObjectInRepo(t, repo2.ID, &content) storeObjectInRepo(t, repo2.ID, &content)
@ -329,7 +330,7 @@ func TestAPILFSUpload(t *testing.T) {
setting.LFS.StartServer = true setting.LFS.StartServer = true
repo := createLFSTestRepository(t, "upload") repo := createLFSTestRepository(t, "lfs-upload-repo")
content := []byte("dummy3") content := []byte("dummy3")
oid := storeObjectInRepo(t, repo.ID, &content) oid := storeObjectInRepo(t, repo.ID, &content)
@ -433,7 +434,7 @@ func TestAPILFSVerify(t *testing.T) {
setting.LFS.StartServer = true setting.LFS.StartServer = true
repo := createLFSTestRepository(t, "verify") repo := createLFSTestRepository(t, "lfs-verify-repo")
content := []byte("dummy3") content := []byte("dummy3")
oid := storeObjectInRepo(t, repo.ID, &content) oid := storeObjectInRepo(t, repo.ID, &content)