Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions models/git/lfs_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@
if ownerID == 0 {
return ErrLFSUnauthorizedAction{repo.ID, "undefined", mode}
}
if ownerID == user_model.ActionsUserID {
taskId, ok := ctx.Value(access_model.ActionsTaskIDKey).(int64)

Check failure on line 190 in models/git/lfs_lock.go

View workflow job for this annotation

GitHub Actions / lint-backend

var-naming: var taskId should be taskID (revive)

Check failure on line 190 in models/git/lfs_lock.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

var-naming: var taskId should be taskID (revive)

Check failure on line 190 in models/git/lfs_lock.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

var-naming: var taskId should be taskID (revive)
if !ok || taskId == 0 {
return ErrLFSUnauthorizedAction{repo.ID, user_model.ActionsUserName, mode}
}
perm, err := access_model.GetActionsUserRepoPermission(ctx, repo, user_model.NewActionsUser(), taskId)
if err != nil {
return err
}
if !perm.CanAccess(mode, unit.TypeCode) {
return ErrLFSUnauthorizedAction{repo.ID, user_model.ActionsUserName, mode}
}
return nil
}
u, err := user_model.GetUserByID(ctx, ownerID)
if err != nil {
return err
Expand Down
35 changes: 35 additions & 0 deletions models/perm/access/repo_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ package access

import (
"context"
"errors"
"fmt"
"slices"

actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
perm_model "code.gitea.io/gitea/models/perm"
Expand Down Expand Up @@ -253,6 +255,39 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) {
}
}

type ActionsTaskIDKeyType struct{}

// ActionsTaskIDKey is the context key for actions task ID in modules without context service like lfs locks
var ActionsTaskIDKey ActionsTaskIDKeyType

// 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
func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) {
defer func() {
Expand Down
21 changes: 10 additions & 11 deletions models/user/user_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,16 @@ func IsGiteaActionsUserName(name string) bool {
// 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,
AllowCreateOrganization: true,
Visibility: structs.VisibleTypePublic,
ID: ActionsUserID,
Name: ActionsUserName,
LowerName: ActionsUserName,
IsActive: true,
FullName: "Gitea Actions",
Email: ActionsUserEmail,
KeepEmailPrivate: true,
LoginName: ActionsUserName,
Type: UserTypeBot,
Visibility: structs.VisibleTypePublic,
}
}

Expand Down
19 changes: 1 addition & 18 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ import (
"net/http"
"strings"

actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
Expand Down Expand Up @@ -190,27 +189,11 @@ func repoAssignment() func(ctx *context.APIContext) {

if ctx.Doer != nil && ctx.Doer.ID == user_model.ActionsUserID {
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 {
ctx.APIErrorInternal(err)
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 {
needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
if err != nil {
Expand Down
25 changes: 6 additions & 19 deletions routers/web/repo/githttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"sync"
"time"

actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
Expand Down Expand Up @@ -190,29 +189,17 @@ func httpBase(ctx *context.Context) *serviceHandler {

if ctx.Data["IsActionsToken"] == true {
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 {
ctx.ServerError("GetTaskByID", err)
return nil
}
if task.RepoID != repo.ID {
ctx.PlainText(http.StatusForbidden, "User permission denied")
ctx.ServerError("GetUserRepoPermission", err)
return nil
}

if task.IsForkPullRequest {
if accessMode > perm.AccessModeRead {
ctx.PlainText(http.StatusForbidden, "User permission denied")
return nil
}
environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeRead))
} 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))
if !p.CanAccess(accessMode, unitType) {
ctx.PlainText(http.StatusNotFound, "Repository not found")
return nil
}
environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType)))
} else {
p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
Expand Down
8 changes: 7 additions & 1 deletion services/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,13 @@ func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {

// ToLFSLock convert a LFSLock to api.LFSLock
func ToLFSLock(ctx context.Context, l *git_model.LFSLock) *api.LFSLock {
u, err := user_model.GetUserByID(ctx, l.OwnerID)
var u *user_model.User
var err error
if l.OwnerID == user_model.ActionsUserID {
u = user_model.NewActionsUser()
} else {
u, err = user_model.GetUserByID(ctx, l.OwnerID)
}
if err != nil {
return nil
}
Expand Down
20 changes: 18 additions & 2 deletions services/lfs/locks.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
package lfs

import (
go_context "context"
"net/http"
"strconv"
"strings"

auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/json"
lfs_module "code.gitea.io/gitea/modules/lfs"
Expand Down Expand Up @@ -175,7 +177,14 @@ func PostLockHandler(ctx *context.Context) {
return
}

lock, err := git_model.CreateLFSLock(ctx, repository, &git_model.LFSLock{
var lockCtx go_context.Context = ctx
// Pass Actions Task ID in context if creating lock using Actions Job Token
if ctx.Data["IsActionsToken"] == true {
taskID := ctx.Data["ActionsTaskID"].(int64)
lockCtx = go_context.WithValue(lockCtx, access_model.ActionsTaskIDKey, taskID)
}

lock, err := git_model.CreateLFSLock(lockCtx, repository, &git_model.LFSLock{
Path: req.Path,
OwnerID: ctx.Doer.ID,
})
Expand Down Expand Up @@ -315,7 +324,14 @@ func UnLockHandler(ctx *context.Context) {
return
}

lock, err := git_model.DeleteLFSLockByID(ctx, ctx.PathParamInt64("lid"), repository, ctx.Doer, req.Force)
var lockCtx go_context.Context = ctx
// Pass Actions Task ID in context if deleting lock using Actions Job Token
if ctx.Data["IsActionsToken"] == true {
taskID := ctx.Data["ActionsTaskID"].(int64)
lockCtx = go_context.WithValue(lockCtx, access_model.ActionsTaskIDKey, taskID)
}

lock, err := git_model.DeleteLFSLockByID(lockCtx, ctx.PathParamInt64("lid"), repository, ctx.Doer, req.Force)
if err != nil {
if git_model.IsErrLFSUnauthorizedAction(err) {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="gitea-lfs"`)
Expand Down
14 changes: 3 additions & 11 deletions services/lfs/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"strings"
"time"

actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git"
perm_model "code.gitea.io/gitea/models/perm"
Expand Down Expand Up @@ -549,19 +548,12 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho

if ctx.Data["IsActionsToken"] == true {
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 {
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
}
if task.RepoID != repository.ID {
return false
}

if task.IsForkPullRequest {
return accessMode <= perm_model.AccessModeRead
}
return accessMode <= perm_model.AccessModeWrite
return perm.CanAccess(accessMode, unit.TypeCode)
}

// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess
Expand Down
119 changes: 119 additions & 0 deletions tests/integration/actions_job_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// 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"
api "code.gitea.io/gitea/modules/structs"

Check failure on line 17 in tests/integration/actions_job_token_test.go

View workflow job for this annotation

GitHub Actions / lint-backend

duplicated-imports: Package "code.gitea.io/gitea/modules/structs" already imported (revive)

Check failure on line 17 in tests/integration/actions_job_token_test.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

duplicated-imports: Package "code.gitea.io/gitea/modules/structs" already imported (revive)

Check failure on line 17 in tests/integration/actions_job_token_test.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

duplicated-imports: Package "code.gitea.io/gitea/modules/structs" already imported (revive)

"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)
}))

if isFork {
context.ExpectedCode = 403
}
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 = 500
t.Run("Fail to Create Repository", doAPICreateRepository(context, true))

context.ExpectedCode = 403
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 api.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)
}))
})
}
Loading
Loading