refactor: carry the API token on the doer (#39690)

The token a request authenticated with now lives on the doer, so
permission and visibility checks apply its scope from the doer alone.
Replaces https://github.com/go-gitea/gitea/pull/39167, following
https://github.com/go-gitea/gitea/pull/39167#issuecomment-6063839619.

1. Public-only tokens get 404 instead of 403 for hidden users, orgs and
repos, like GitHub.
2. Org ownership, site admin, repo and org creation, generation and
transfer endpoints reject public-only tokens.
3. API pull request responses set `head.repo` to `null` and
`head.repo_id` to `-1` when the caller cannot see the head repository.
4. Public-only tokens see org memberships like anonymous callers, so
concealed members stay hidden.
5. Site admin access in activity feeds, follower lists, org members,
Actions run lists and packages needs a session or an `all`-scoped token.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwindandwxiaoguang authored and GitHub committed 2026-10-19 00:34:01 +00:00
1 parent 7e62dda791
commit 5264304a34
96 files changed
+583 -686

No files matched your search

@@ -281,10 +281,9 @@ func TestPackageGenericPublicOnlyTokenLimitedOwner(t *testing.T) {
// a public-only read:package token (even the owner's own) must be refused
publicOnlyToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadPackage, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", base+"/file.bin").AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusForbidden)
// same via the v1 package API surface (checkTokenPublicOnly)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/pkg/1.0.0", owner.Name)).AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusForbidden)
MakeRequest(t, req, http.StatusNotFound)
// a normal read:package token still works, proving only public-only is restricted
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadPackage)
+11 -4
View File
@@ -30,6 +30,7 @@ type projectScope struct {
issueID int64
// a second such issue, deliberately left off the board
foreignIssueID int64
publicReader string
}
func TestAPIProjects(t *testing.T) {
@@ -43,10 +44,12 @@ func TestAPIProjects(t *testing.T) {
outsider := getUserToken(t, "user8", auth_model.AccessTokenScopeWriteIssue,
auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeReadUser)
publicOnlyOutsider := getUserToken(t, "user8", auth_model.AccessTokenScopeReadIssue,
auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
for _, scope := range []projectScope{
{"Repository", "/api/v1/repos/user2/repo1/projects", 1, 11},
{"Organization", "/api/v1/orgs/org3/projects", 16, 17},
{"User", "/api/v1/user/projects", 1, 11},
{"Repository", "/api/v1/repos/user2/repo1/projects", 1, 11, ""},
{"Organization", "/api/v1/orgs/org3/projects", 16, 17, publicOnlyOutsider},
{"User", "/api/v1/user/projects", 1, 11, ""},
} {
t.Run(scope.name, func(t *testing.T) {
testProjectLifecycle(t, scope, token)
@@ -215,6 +218,10 @@ func testProjectLifecycle(t *testing.T, scope projectScope, token string) {
issues := *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]api.Issue{})
require.Len(t, issues, 1)
assert.Equal(t, scope.issueID, issues[0].ID)
if scope.publicReader != "" {
req = NewRequest(t, "GET", fmt.Sprintf("%s/columns/%d/issues", projectURL, columnIDs[1])).AddTokenAuth(scope.publicReader)
assert.Len(t, *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]api.Issue{}), 1)
}
// the sibling column must not report the same issue
req = NewRequest(t, "GET", fmt.Sprintf("%s/columns/%d/issues", projectURL, columnIDs[0])).AddTokenAuth(token)
@@ -288,7 +295,7 @@ func testAPIProjectVisibility(t *testing.T, outsider string) {
auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadIssue,
auth_model.AccessTokenScopePublicOnly)
for _, url := range []string{"/api/v1/orgs/private_org35/projects", "/api/v1/users/private_org35/projects"} {
MakeRequest(t, NewRequest(t, "GET", url).AddTokenAuth(publicOnly), http.StatusForbidden)
MakeRequest(t, NewRequest(t, "GET", url).AddTokenAuth(publicOnly), http.StatusNotFound)
}
// a public org's board is readable by an outsider but not writable
+18 -7
View File
@@ -44,6 +44,7 @@ func TestAPIUserReposPublicOnly(t *testing.T) {
assert.False(t, repo.Private)
}
assert.NotContains(t, repoNames(repos), "user2/repo2")
assert.Equal(t, strconv.Itoa(len(repos)), resp.Header().Get("X-Total-Count"))
}
func repoNames(repos []api.Repository) []string {
@@ -78,12 +79,14 @@ func TestAPIActivityFeedsPublicOnly(t *testing.T) {
DecodeJSON(t, resp, &activities)
assert.NotEmpty(t, activities)
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds").
AddTokenAuth(publicToken)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &activities)
assertPublicActivitiesOnly(t, activities)
for _, tokenOwner := range []string{"user2", "user1"} {
publicToken := getUserToken(t, tokenOwner, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds").
AddTokenAuth(publicToken)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &activities)
assertPublicActivitiesOnly(t, activities)
}
orgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds").
@@ -100,7 +103,7 @@ func TestAPIActivityFeedsPublicOnly(t *testing.T) {
assertPublicActivitiesOnly(t, activities)
}
func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
func TestAPIOrgMembershipPublicOnly(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2 is a member of the private org private_org35
@@ -115,6 +118,14 @@ func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
req = NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(publicToken)
MakeRequest(t, req, http.StatusNotFound)
var members, publicMembers []*api.User
DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/members").AddTokenAuth(token), http.StatusOK), &members)
DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/members").AddTokenAuth(publicToken), http.StatusOK), &publicMembers)
assert.Len(t, members, 3)
assert.Len(t, publicMembers, 2)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/members/user4").AddTokenAuth(token), http.StatusNoContent)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/members/user4").AddTokenAuth(publicToken), http.StatusSeeOther)
}
func TestAPITeamReposPublicOnly(t *testing.T) {
+28
View File
@@ -142,6 +142,34 @@ func TestAPIViewPulls(t *testing.T) {
assert.Len(t, files, 1)
}))
}
t.Run("HeadRepoHiddenWhenInaccessible", func(t *testing.T) {
session := loginUser(t, "user31")
forkToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}).AddTokenAuth(forkToken), http.StatusAccepted)
visible := DecodeJSON(t, MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/pulls", &api.CreatePullRequestOption{
Head: "user31:branch2", Base: "master", Title: "pull from a private user's fork",
}).AddTokenAuth(forkToken), http.StatusCreated), &api.PullRequest{})
require.NotNil(t, visible.Head.Repository)
require.NotEmpty(t, visible.Head.Sha)
prURL := fmt.Sprintf("/api/v1/repos/user2/repo1/pulls/%d", visible.Index)
var listed []*api.PullRequest
DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1/pulls?poster=user31"), http.StatusOK), &listed)
require.Len(t, listed, 1)
for _, hidden := range []*api.PullRequest{
DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", prURL), http.StatusOK), &api.PullRequest{}),
listed[0],
} {
assert.Nil(t, hidden.Head.Repository)
assert.EqualValues(t, -1, hidden.Head.RepoID)
assert.Equal(t, visible.Head.Name, hidden.Head.Name)
assert.Equal(t, visible.Head.Sha, hidden.Head.Sha)
}
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: 1, Index: visible.Index})
assert.NotNil(t, convert.ToPayloadPullRequest(t.Context(), pr, nil).Head.Repository)
})
}
func TestAPIViewPullsByBaseHead(t *testing.T) {
+1 -1
View File
@@ -21,7 +21,7 @@ func TestUserHeatmap(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUsername := "user1"
normalUsername := "user2"
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser)
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeAll)
fakeNow := time.Date(2011, 10, 20, 0, 0, 0, 0, time.Local)
timeutil.MockSet(fakeNow)
@@ -8,6 +8,7 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
@@ -32,10 +33,12 @@ func TestAPIPublicOnlySelfUserRoutes(t *testing.T) {
auth_model.AccessTokenScopeWriteUser,
)
t.Run("PrivateProfileForbidden", func(t *testing.T) {
t.Run("PrivateProfileUnreachable", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(privateReadUserToken), http.StatusForbidden)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(privateReadUserToken), http.StatusNotFound)
require.NoError(t, db.Insert(t.Context(), &user_model.Redirect{LowerName: "old-user31", RedirectUserID: privateUser.ID}))
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/old-user31").AddTokenAuth(privateReadUserToken), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(privateReadUserToken), http.StatusForbidden)
})
+8 -8
View File
@@ -277,16 +277,16 @@ func TestAttachmentTokenScopes(t *testing.T) {
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
readIssueStatus: http.StatusOK,
readRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusNotFound,
publicOnlyRepoStatus: http.StatusNotFound,
},
{
name: "RepoPrivateIssueAttachment",
url: "/user2/repo2/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
readIssueStatus: http.StatusOK,
readRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusNotFound,
publicOnlyRepoStatus: http.StatusNotFound,
},
{
name: "GlobalPublicReleaseAttachment",
@@ -309,16 +309,16 @@ func TestAttachmentTokenScopes(t *testing.T) {
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
readIssueStatus: http.StatusForbidden,
readRepoStatus: http.StatusOK,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusNotFound,
publicOnlyRepoStatus: http.StatusNotFound,
},
{
name: "RepoPrivateReleaseAttachment",
url: "/user2/repo2/releases/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
readIssueStatus: http.StatusForbidden,
readRepoStatus: http.StatusOK,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusNotFound,
publicOnlyRepoStatus: http.StatusNotFound,
},
}
+1 -1
View File
@@ -201,7 +201,7 @@ func TestDownloadRepoContentTokenScopes(t *testing.T) {
MakeRequest(t, NewRequest(t, tc.method, tc.url).AddTokenAuth(miscToken), http.StatusForbidden)
MakeRequest(t, NewRequest(t, tc.method, tc.url).AddTokenAuth(ownerReadToken), tc.withScope)
publicOnlyStatus := http.StatusForbidden
publicOnlyStatus := http.StatusNotFound
if tc.publicOnlyOK {
publicOnlyStatus = tc.withScope
}
+1 -1
View File
@@ -129,7 +129,7 @@ func testGitSmartHTTPTokenScopes(t *testing.T) {
t.Run("public-only scope rejects private repo", func(t *testing.T) {
path := "/user2/repo2/info/refs?service=git-upload-pack"
MakeRequest(t, NewRequest(t, "GET", path).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
MakeRequest(t, NewRequest(t, "GET", path).AddTokenAuth(publicOnlyToken), http.StatusNotFound)
})
}
+1 -1
View File
@@ -123,7 +123,7 @@ func testPrivateActivityHelperHasHeatmapContentFromPublic(t *testing.T) bool {
}
func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session *TestSession) bool {
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser).
AddTokenAuth(token)
-35
View File
@@ -61,41 +61,6 @@ func TestAPIPullUpdate(t *testing.T) {
})
}
// TestAPIPullUpdatePublicOnlyToken verifies that a public-only API token cannot
// update (push into) a PR whose head repo is private, even when the base repo
// named in the route is public.
func TestAPIPullUpdatePublicOnlyToken(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
pr := createOutdatedPR(t, user, org26)
require.NoError(t, pr.LoadBaseRepo(t.Context()))
require.NoError(t, pr.LoadHeadRepo(t.Context()))
require.NoError(t, pr.LoadIssue(t.Context()))
// make the head repo private while the base repo stays public
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(),
&repo_model.Repository{ID: pr.HeadRepo.ID, IsPrivate: true}, "is_private"))
// a public-only write token must be refused (404), not perform the push
publicOnlyToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopePublicOnly)
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusNotFound)
// the head branch must still be outdated (no push happened)
diffCount, err := git.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
require.NoError(t, err)
assert.Equal(t, 1, diffCount.Behind)
// a normal write token still works, proving the guard is scope-specific
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteRepository)
req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
})
}
func updateRepoPullRequestConfig(t *testing.T, repoID int64, update func(*repo_model.PullRequestsConfig)) {
t.Helper()
@@ -27,7 +27,7 @@ func TestRepoWebTokenScopes(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
assertBasicAuthStatus(t, test.url, miscToken, http.StatusForbidden)
assertBasicAuthStatus(t, test.url, publicOnlyToken, http.StatusForbidden)
assertBasicAuthStatus(t, test.url, publicOnlyToken, http.StatusNotFound)
assertBasicAuthStatus(t, test.url, readToken, http.StatusOK)
})
}
@@ -28,7 +28,7 @@ func TestAPIWorkflowRun(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/user/actions", "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
})
t.Run("OrgRuns", func(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions", "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions", "User1", 802, auth_model.AccessTokenScopeAll)
})
t.Run("RepoRuns", func(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", "User2", 802, auth_model.AccessTokenScopeReadRepository)