fix(projects): only require project write to move board cards (#40161)

Follow-up to https://github.com/go-gitea/gitea/pull/30205, per
https://github.com/go-gitea/gitea/pull/30205#discussion_r4435046605.
Moving a card on a project board is a project operation, so project
write is enough again. Repository boards already show every card to
project writers, who then got a 404 when moving one.

The per-card archived check is removed too. The frontend sends every
card of the target column, so one card from an archived repository
rejected all drops and reorders into that column, silently. Project
workflows already skip archived repositories, and an archived
repository's own board is read-only.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-11-01 10:46:53 +00:00
committed by GitHub
parent 51368e1997
commit c62e07f18a
3 changed files with 22 additions and 74 deletions

View File

@@ -30,8 +30,8 @@ const (
TypeWiki // 5 Wiki TypeWiki // 5 Wiki
TypeExternalWiki // 6 ExternalWiki TypeExternalWiki // 6 ExternalWiki
TypeExternalTracker // 7 ExternalTracker TypeExternalTracker // 7 ExternalTracker
TypeProjects // 8 Projects TypeProjects // 8 Projects: implies that the doer can read some information from issue & PR, org-level is not well-designed
TypePackages // 9 Packages TypePackages // 9 Packages: for most cases, it is for owner-level (not well-designed though)
TypeActions // 10 Actions TypeActions // 10 Actions
) )

View File

@@ -5,10 +5,8 @@ package project
import ( import (
"errors" "errors"
"net/http"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
project_model "gitea.dev/models/project" project_model "gitea.dev/models/project"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/web" "gitea.dev/modules/web"
@@ -191,28 +189,11 @@ func MoveIssues(ctx *context.Context) {
return return
} }
repoPerms := map[int64]access_model.Permission{}
for _, issue := range movedIssues { for _, issue := range movedIssues {
if !project.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) { if !project.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return return
} }
perm, ok := repoPerms[issue.RepoID]
if !ok {
if perm, err = access_model.GetDoerRepoPermission(ctx, issue.Repo, ctx.Doer); err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return
}
repoPerms[issue.RepoID] = perm
}
if !perm.CanReadIssuesOrPulls(issue.IsPull) {
ctx.NotFound(nil)
return
}
if issue.Repo.IsArchived {
ctx.HTTPError(http.StatusLocked, "repo is archived")
return
}
} }
if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil { if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {

View File

@@ -11,13 +11,9 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
project_model "gitea.dev/models/project" project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
@@ -72,54 +68,25 @@ func TestFindColumn(t *testing.T) {
} }
} }
func TestMoveIssuesNeedsIssueRead(t *testing.T) { func TestMoveIssuesNeedsOnlyProjectWrite(t *testing.T) {
for _, tc := range []struct { unittest.PrepareTestEnv(t)
name string require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: 3, IsArchived: true}, "is_archived"))
doerID int64 ctx, _ := contexttest.MockContext(t, "POST /org3/-/projects")
repoBoard bool contexttest.LoadUser(t, ctx, 15)
archived bool ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
status int project := &project_model.Project{Title: "board", OwnerID: 3, CreatorID: 2, Type: project_model.TypeOrganization}
}{ require.NoError(t, project_model.NewProject(ctx, project))
{"OwnerBoardReader", 2, false, false, http.StatusOK}, require.NoError(t, project_model.NewColumn(ctx, &project_model.Column{Title: "Backlog", ProjectID: project.ID, Default: true}))
{"OwnerBoardWithoutIssueAccess", 15, false, false, http.StatusNotFound}, column := &project_model.Column{Title: "Done", ProjectID: project.ID}
{"OwnerBoardArchivedRepo", 2, false, true, http.StatusLocked}, require.NoError(t, project_model.NewColumn(ctx, column))
{"RepoBoardProjectsWriterWithoutIssueRead", 4, true, false, http.StatusNotFound}, issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
} { _, _, err := issues_model.IssueAssignOrRemoveProject(ctx, issue, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), []int64{project.ID})
t.Run(tc.name, func(t *testing.T) { require.NoError(t, err)
unittest.PrepareTestEnv(t) ctx.SetPathParam("id", strconv.FormatInt(project.ID, 10))
if tc.repoBoard { ctx.SetPathParam("columnID", strconv.FormatInt(column.ID, 10))
_, err := db.Exec(t.Context(), "UPDATE team SET authorize = ? WHERE id = ?", perm.AccessModeNone, 2) ctx.Req.Body = io.NopCloser(strings.NewReader(fmt.Sprintf(`{"issues":[{"issueID":%d,"sorting":0}]}`, issue.ID)))
require.NoError(t, err)
_, err = db.DeleteByBean(t.Context(), &organization.TeamUnit{TeamID: 2, Type: unit.TypeIssues})
require.NoError(t, err)
}
if tc.archived {
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: 3, IsArchived: true}, "is_archived"))
}
ctx, _ := contexttest.MockContext(t, "POST /org3/-/projects")
contexttest.LoadUser(t, ctx, tc.doerID)
project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 2})
if tc.repoBoard {
contexttest.LoadRepo(t, ctx, 3)
} else {
ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
project = &project_model.Project{Title: "board", OwnerID: 3, CreatorID: 2, Type: project_model.TypeOrganization}
require.NoError(t, project_model.NewProject(ctx, project))
require.NoError(t, project_model.NewColumn(ctx, &project_model.Column{Title: "Backlog", ProjectID: project.ID, Default: true}))
}
column := &project_model.Column{Title: "Done", ProjectID: project.ID}
require.NoError(t, project_model.NewColumn(ctx, column))
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
_, _, err := issues_model.IssueAssignOrRemoveProject(ctx, issue, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), []int64{project.ID})
require.NoError(t, err)
ctx.SetPathParam("id", strconv.FormatInt(project.ID, 10))
ctx.SetPathParam("columnID", strconv.FormatInt(column.ID, 10))
ctx.Req.Body = io.NopCloser(strings.NewReader(fmt.Sprintf(`{"issues":[{"issueID":%d,"sorting":0}]}`, issue.ID)))
MoveIssues(ctx) MoveIssues(ctx)
assert.Equal(t, tc.status, ctx.Resp.WrittenStatus()) assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
card := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: issue.ID, ProjectID: project.ID}) unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: issue.ID, ProjectID: project.ID, ProjectColumnID: column.ID})
assert.Equal(t, tc.status == http.StatusOK, card.ProjectColumnID == column.ID)
})
}
} }