// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration import ( "encoding/base64" "fmt" "net/http" "net/http/httptest" "net/url" "strconv" "testing" actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestActionsJobTokenPermissiveAccess(t *testing.T) { cases := []struct { name string isFork bool ownerPermMode repo_model.ActionsTokenPermissionMode ownerMaxPerms map[unit_model.Type]perm.AccessMode repoPermMode repo_model.ActionsTokenPermissionMode repoMaxPerms map[unit_model.Type]perm.AccessMode expectGitAccess perm.AccessMode }{ { name: "OwnerConfig-Permissive", ownerPermMode: repo_model.ActionsTokenPermissionModePermissive, expectGitAccess: perm.AccessModeWrite, }, { name: "OwnerConfig-Permissive-CodeNone", ownerPermMode: repo_model.ActionsTokenPermissionModePermissive, ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone}, expectGitAccess: perm.AccessModeNone, }, { name: "OwnerConfig-Restricted", ownerPermMode: repo_model.ActionsTokenPermissionModeRestricted, expectGitAccess: perm.AccessModeRead, }, // repo uses its own settings, so owner settings should not affect it { name: "SameRepo-Permissive", ownerPermMode: repo_model.ActionsTokenPermissionModeRestricted, ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone}, repoPermMode: repo_model.ActionsTokenPermissionModePermissive, expectGitAccess: perm.AccessModeWrite, }, { name: "SameRepo-Permissive-CodeNone", ownerPermMode: repo_model.ActionsTokenPermissionModePermissive, ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeRead}, repoPermMode: repo_model.ActionsTokenPermissionModePermissive, repoMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone}, expectGitAccess: perm.AccessModeNone, }, { name: "SameRepo-Restricted", repoPermMode: repo_model.ActionsTokenPermissionModeRestricted, expectGitAccess: perm.AccessModeRead, }, // forks should be always restricted to max read access for code { name: "Fork-Permissive", repoPermMode: repo_model.ActionsTokenPermissionModePermissive, isFork: true, expectGitAccess: perm.AccessModeRead, }, { name: "Fork-Restricted", repoPermMode: repo_model.ActionsTokenPermissionModeRestricted, isFork: true, expectGitAccess: perm.AccessModeRead, }, { name: "Fork-Restricted-CodeNone", repoPermMode: repo_model.ActionsTokenPermissionModeRestricted, repoMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone}, isFork: true, expectGitAccess: perm.AccessModeNone, }, } onGiteaRun(t, func(t *testing.T, u *url.URL) { task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: task.RepoID}) repoActionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions) repoActionsCfg := repoActionsUnit.ActionsConfig() ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(t.Context(), repo.OwnerID) require.NoError(t, err) _, err = db.GetEngine(t.Context()).ID(task.RepoID).Cols("is_private").Update(&repo_model.Repository{IsPrivate: true}) require.NoError(t, err) assertRespCodeForSuccess := func(t *testing.T, resp *httptest.ResponseRecorder, succeed bool) { if succeed { assert.True(t, 200 <= resp.Code && resp.Code < 300, "Expected success status code, got %d", resp.Code) } else { assert.True(t, 400 <= resp.Code && resp.Code < 500, "Expected client error status code, got %d", resp.Code) } } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { // prepare owner's token permissions settings ownerActionsCfg.TokenPermissionMode = tt.ownerPermMode ownerActionsCfg.MaxTokenPermissions = util.Iif(tt.ownerMaxPerms == nil, nil, &repo_model.ActionsTokenPermissions{UnitAccessModes: tt.ownerMaxPerms}) require.NoError(t, actions_model.SetOwnerActionsConfig(t.Context(), repo.OwnerID, ownerActionsCfg)) // prepare repo's token permissions settings repoActionsCfg.OverrideOwnerConfig = tt.repoPermMode != "" || tt.repoMaxPerms != nil repoActionsCfg.TokenPermissionMode = tt.repoPermMode repoActionsCfg.MaxTokenPermissions = util.Iif(tt.repoMaxPerms == nil, nil, &repo_model.ActionsTokenPermissions{UnitAccessModes: tt.repoMaxPerms}) require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit)) // prepare task and its token task.GenerateAndFillToken() task.Status = actions_model.StatusRunning task.IsForkPullRequest = tt.isFork err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request") require.NoError(t, err) require.NoError(t, task.LoadJob(t.Context())) require.NoError(t, task.Job.LoadRun(t.Context())) task.Job.Run.IsForkPullRequest = tt.isFork require.NoError(t, actions_model.UpdateRun(t.Context(), task.Job.Run, "is_fork_pull_request")) testURL := *u testURL.User = url.UserPassword("gitea-actions", task.Token) t.Run("ReadGitContent", func(t *testing.T) { testURL.Path = "/user5/repo4.git/HEAD" resp := MakeRequest(t, NewRequest(t, "GET", testURL.String()), NoExpectedStatus) assertRespCodeForSuccess(t, resp, tt.expectGitAccess != perm.AccessModeNone) testURL.Path = "/user5/repo4.git/info/lfs/locks" req := NewRequest(t, "GET", testURL.String()).SetHeader("Accept", lfs.MediaType) resp = MakeRequest(t, req, NoExpectedStatus) assertRespCodeForSuccess(t, resp, tt.expectGitAccess != perm.AccessModeNone) }) t.Run("WriteGitContent", func(t *testing.T) { req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/contents/test-filename", repo.FullName()), &structs.CreateFileOptions{ FileOptions: structs.FileOptions{NewBranchName: "new-branch" + t.Name()}, ContentBase64: base64.StdEncoding.EncodeToString([]byte(`dummy content`)), }).AddTokenAuth(task.Token) resp := MakeRequest(t, req, NoExpectedStatus) assertRespCodeForSuccess(t, resp, tt.expectGitAccess == perm.AccessModeWrite) testURL.Path = "/user5/repo4.git/info/lfs/objects/batch" req = NewRequestWithJSON(t, "POST", testURL.String(), lfs.BatchRequest{Operation: "upload"}).SetHeader("Accept", lfs.MediaType) resp = MakeRequest(t, req, NoExpectedStatus) assertRespCodeForSuccess(t, resp, tt.expectGitAccess == perm.AccessModeWrite) }) t.Run("NoOtherPermissions", func(t *testing.T) { req := NewRequest(t, "DELETE", "/api/v1/repos/"+repo.FullName()).AddTokenAuth(task.Token) resp := MakeRequest(t, req, NoExpectedStatus) assertRespCodeForSuccess(t, resp, false) }) }) } }) } func TestActionsCrossRepoAccess(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user2") token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization) // 1. Create Organization orgName := "org-cross-test" req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &structs.CreateOrgOption{ UserName: orgName, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) owner, err := org_model.GetOrgByName(t.Context(), orgName) require.NoError(t, err) // 2. Create Two Repositories in owner createRepoInOrg := func(name string) int64 { req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), &structs.CreateRepoOption{ Name: name, AutoInit: true, }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) repo := DecodeJSON(t, resp, &structs.Repository{}) return repo.ID } repoAID := createRepoInOrg("repo-A") repoBID := createRepoInOrg("repo-B") // 3. Enable Actions in Repo A (Source) and Repo B (Target) enableActions := func(repoID int64) { err := db.Insert(t.Context(), &repo_model.RepoUnit{ RepoID: repoID, Type: unit_model.TypeActions, Config: &repo_model.ActionsConfig{ TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive, }, }) require.NoError(t, err) } enableActions(repoAID) enableActions(repoBID) // 4. Create Task in Repo A, and use A's token to access B taskA := createActionTask(t, repoAID, false) testCtxA := APITestContext{ Session: emptyTestSession(t), Token: taskA.Token, Username: orgName, Reponame: "repo-B", } testCtxA.ExpectedCode = http.StatusOK t.Run("PublicCrossRepoAccess", doAPIGetRepository(testCtxA, func(t *testing.T, r structs.Repository) { assert.Equal(t, "repo-B", r.Name) })) // make repo-B be private req = NewRequestWithJSON(t, "PATCH", "/api/v1/repos/org-cross-test/repo-B", &structs.EditRepoOption{Private: new(true)}).AddTokenAuth(token) MakeRequest(t, req, http.StatusOK) testCtxA.ExpectedCode = http.StatusNotFound t.Run("NoPrivateCrossRepoAccess", doAPIGetRepository(testCtxA, nil)) ownerActionsCfg := actions_model.OwnerActionsConfig{AllowedCrossRepoIDs: []int64{repoBID}} require.NoError(t, actions_model.SetOwnerActionsConfig(t.Context(), owner.ID, ownerActionsCfg)) testCtxA.ExpectedCode = http.StatusOK t.Run("AccessToSelectedPrivateRepo", doAPIGetRepository(testCtxA, func(t *testing.T, r structs.Repository) { assert.Equal(t, "repo-B", r.Name) })) t.Run("RepoTransfer", func(t *testing.T) { ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(t.Context(), owner.ID) require.NoError(t, err) assert.Contains(t, ownerActionsCfg.AllowedCrossRepoIDs, repoBID) // Transfer Repository to user4 req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/repo-B/transfer", orgName), &structs.TransferRepoOption{ NewOwner: "user4", }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Accept transfer as user4 session4 := loginUser(t, "user4") token4 := getTokenForLoggedInUser(t, session4, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/repo-B/transfer/accept", orgName)).AddTokenAuth(token4) MakeRequest(t, req, http.StatusAccepted) // Verify it is removed from the org's config ownerActionsCfg, err = actions_model.GetOwnerActionsConfig(t.Context(), owner.ID) require.NoError(t, err) assert.NotContains(t, ownerActionsCfg.AllowedCrossRepoIDs, repoBID) }) }) } func TestActionsJobTokenPermissions(t *testing.T) { defer tests.PrepareTestEnv(t)() t.Run("WriteIssue", TestActionsJobTokenPermissionsWriteIssue) } func TestActionsJobTokenPermissionsWriteIssue(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) require.Equal(t, repo.ID, task.RepoID) require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{ RepoID: repo.ID, Type: unit_model.TypeActions, Config: &repo_model.ActionsConfig{}, })) repoActionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions) repoActionsCfg := repoActionsUnit.ActionsConfig() repoActionsCfg.OverrideOwnerConfig = true repoActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModePermissive repoActionsCfg.MaxTokenPermissions = nil require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit)) task.GenerateAndFillToken() task.Status = actions_model.StatusRunning require.NoError(t, actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status")) session := loginUser(t, user.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteRepository) labelURL := fmt.Sprintf("/api/v1/repos/%s/%s/labels", user.Name, repo.Name) req := NewRequestWithJSON(t, "POST", labelURL, &structs.CreateLabelOption{ Name: "task-label", Color: "0e8a16", }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) label := DecodeJSON(t, resp, &structs.Label{}) issueURL := fmt.Sprintf("/api/v1/repos/%s/%s/issues", user.Name, repo.Name) req = NewRequestWithJSON(t, "POST", issueURL, &structs.CreateIssueOption{ Title: "issue for actions token label deletion", }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusCreated) issue := DecodeJSON(t, resp, &structs.Issue{}) taskToken := task.Token require.NotEmpty(t, taskToken) issueLabelsURL := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", user.Name, repo.Name, issue.Index) req = NewRequestWithJSON(t, "POST", issueLabelsURL, &structs.IssueLabelsOption{ Labels: []any{label.ID}, }).AddTokenAuth(taskToken) MakeRequest(t, req, http.StatusOK) req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", issueLabelsURL, label.ID)).AddTokenAuth(taskToken) MakeRequest(t, req, http.StatusNoContent) } func createActionTask(t *testing.T, repoID int64, isFork bool) *actions_model.ActionTask { job := &actions_model.ActionRunJob{ RepoID: repoID, Status: actions_model.StatusRunning, IsForkPullRequest: isFork, JobID: "test_job", Name: "test_job", } require.NoError(t, db.Insert(t.Context(), job)) task := &actions_model.ActionTask{ JobID: job.ID, RepoID: repoID, Status: actions_model.StatusRunning, IsForkPullRequest: isFork, } task.GenerateAndFillToken() require.NoError(t, db.Insert(t.Context(), task)) return task } func TestActionsTokenPermissionsPersistenceWithWorkflow(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) session := loginUser(t, user2.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) // create repos repo1 := createActionsTestRepo(t, token, "actions-permission-repo1", false) repo2 := createActionsTestRepo(t, token, "actions-permission-repo2", true) // add repo2 to owner-level cross-repo access list req := NewRequestWithValues(t, "POST", "/user/settings/actions/general", map[string]string{ "cross_repo_add_target": "true", "cross_repo_add_target_name": repo2.Name, }) session.MakeRequest(t, req, http.StatusOK) // create the runner for repo1 runner1 := newMockRunner() runner1.registerAsRepoRunner(t, user2.Name, repo1.Name, "mock-runner", []string{"ubuntu-latest"}, false) // set repo1 actions token permission mode to "permissive" req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo1.Name), map[string]string{ "token_permission_mode": "permissive", "override_owner_config": "true", }) session.MakeRequest(t, req, http.StatusSeeOther) // set repo2 actions token permission mode to "restricted", and set max permissions req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{ "token_permission_mode": "restricted", "override_owner_config": "true", "enable_max_permissions": "true", "max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeReleases)): "read", }) session.MakeRequest(t, req, http.StatusSeeOther) // create a workflow file with "permission" keyword for repo1 wfTreePath := ".gitea/workflows/test_permissions.yml" wfFileContent := `name: Test Permissions on: push: paths: - '.gitea/workflows/test_permissions.yml' jobs: job-override: runs-on: ubuntu-latest permissions: code: write steps: - run: echo "test perms" ` opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) createWorkflowFile(t, token, user2.Name, repo1.Name, wfTreePath, opts) task1 := runner1.fetchTask(t) task1Token := task1.Secrets["GITEA_TOKEN"] require.NotEmpty(t, task1Token) // should fail: target repo does not allow code access req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo2.Name)).AddTokenAuth(task1Token) MakeRequest(t, req, http.StatusNotFound) // set repo2 max permission to "read" so that the actions token can access code req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{ "token_permission_mode": "restricted", "override_owner_config": "true", "enable_max_permissions": "true", "max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "read", "max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeReleases)): "read", }) session.MakeRequest(t, req, http.StatusSeeOther) // should succeed: target repo now allows code read access for this token req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo2.Name)).AddTokenAuth(task1Token) MakeRequest(t, req, http.StatusOK) // but it should not have write access req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo2.Name), lfs.BatchRequest{Operation: "upload"}). SetHeader("Accept", lfs.MediaType). AddBasicAuth("gitea-actions", task1Token) MakeRequest(t, req, http.StatusUnauthorized) // set repo1&repo2 max permission to "write" so that the actions token can access code req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo1.Name), map[string]string{ "token_permission_mode": "restricted", "override_owner_config": "true", "enable_max_permissions": "true", "max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "write", }) session.MakeRequest(t, req, http.StatusSeeOther) req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{ "token_permission_mode": "restricted", "override_owner_config": "true", "enable_max_permissions": "true", "max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "write", }) session.MakeRequest(t, req, http.StatusSeeOther) // now task1 has write access to repo1, but still only read access to repo2 (different repo) req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo1.Name), lfs.BatchRequest{Operation: "upload"}). SetHeader("Accept", lfs.MediaType). AddBasicAuth("gitea-actions", task1Token) MakeRequest(t, req, http.StatusOK) req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo2.Name), lfs.BatchRequest{Operation: "upload"}). SetHeader("Accept", lfs.MediaType). AddBasicAuth("gitea-actions", task1Token) MakeRequest(t, req, http.StatusUnauthorized) }) }