feat: add commitstatus access token scope (#40144)

Add `read:commitstatus` and `write:commitstatus` token scopes, so CI and
other integrations can report commit statuses without a token that
grants full repository access.

- The commit status endpoints (`/statuses/{sha}`,
`/commits/{ref}/status` and `/commits/{ref}/statuses`) accept the new
scopes, and the repository scopes keep working as they include them
- Creating a status still requires code write permission on the
repository, the scope only narrows what the token may do
- `public-only` tokens still cannot reach private repositories
- `PathGroup` middlewares now run only for a matched path, so unrelated
methods and paths that fall back to the group are not rejected by its
scope check

Fixes: https://github.com/go-gitea/gitea/issues/32625
Ref: https://github.com/go-gitea/gitea/pull/35383
Docs: https://gitea.com/gitea/docs/pulls/622
Signed-off-by: Norbert Szulc <norbert@not7cd.net>
Co-authored-by: Aiden Scandella <git@sca.ndella.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-11-01 06:27:12 +00:00
1 parent b46b820cec
commit da7b81d407
11 files changed
+87 -28

No files matched your search

+15 -2
View File
@@ -24,6 +24,7 @@ const (
AccessTokenScopeCategoryIssue
AccessTokenScopeCategoryRepository
AccessTokenScopeCategoryUser
AccessTokenScopeCategoryCommitStatus
)
// AllAccessTokenScopeCategories contains all access token scope categories
@@ -37,6 +38,7 @@ var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{
AccessTokenScopeCategoryIssue,
AccessTokenScopeCategoryRepository,
AccessTokenScopeCategoryUser,
AccessTokenScopeCategoryCommitStatus,
}
// AccessTokenScopeLevel represents the access levels without a given scope category
@@ -83,6 +85,9 @@ const (
AccessTokenScopeReadUser AccessTokenScope = "read:user"
AccessTokenScopeWriteUser AccessTokenScope = "write:user"
AccessTokenScopeReadCommitStatus AccessTokenScope = "read:commitstatus"
AccessTokenScopeWriteCommitStatus AccessTokenScope = "write:commitstatus"
)
// accessTokenScopeBitmap represents a bitmap of access token scopes.
@@ -119,12 +124,15 @@ const (
accessTokenScopeReadIssueBits accessTokenScopeBitmap = 1 << iota
accessTokenScopeWriteIssueBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadIssueBits
accessTokenScopeReadRepositoryBits accessTokenScopeBitmap = 1 << iota
accessTokenScopeWriteRepositoryBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadRepositoryBits
accessTokenScopeReadRepositoryBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadCommitStatusBits
accessTokenScopeWriteRepositoryBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadRepositoryBits | accessTokenScopeWriteCommitStatusBits
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits
accessTokenScopeReadCommitStatusBits accessTokenScopeBitmap = 1 << iota
accessTokenScopeWriteCommitStatusBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadCommitStatusBits
// The current implementation only supports up to 64 token scopes.
// If we need to support > 64 scopes,
// refactoring the whole implementation in this file (and only this file) is needed.
@@ -143,6 +151,7 @@ var allAccessTokenScopes = []AccessTokenScope{
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue,
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository,
AccessTokenScopeWriteUser, AccessTokenScopeReadUser,
AccessTokenScopeWriteCommitStatus, AccessTokenScopeReadCommitStatus,
}
// allAccessTokenScopeBits contains all access token scopes.
@@ -168,6 +177,8 @@ var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits,
AccessTokenScopeReadUser: accessTokenScopeReadUserBits,
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits,
AccessTokenScopeReadCommitStatus: accessTokenScopeReadCommitStatusBits,
AccessTokenScopeWriteCommitStatus: accessTokenScopeWriteCommitStatusBits,
}
// readAccessTokenScopes maps a scope category to the read permission scope
@@ -182,6 +193,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue,
AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository,
AccessTokenScopeCategoryUser: AccessTokenScopeReadUser,
AccessTokenScopeCategoryCommitStatus: AccessTokenScopeReadCommitStatus,
},
Write: {
AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub,
@@ -193,6 +205,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
AccessTokenScopeCategoryCommitStatus: AccessTokenScopeWriteCommitStatus,
},
}
+3 -1
View File
@@ -17,7 +17,7 @@ type scopeTestNormalize struct {
}
func TestAccessTokenScope_Normalize(t *testing.T) {
assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories())
assert.Equal(t, []string{"activitypub", "admin", "commitstatus", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories())
assert.False(t, AccessTokenScopeNone.HasPermissionScope())
tests := []scopeTestNormalize{
{"", "", nil},
@@ -30,6 +30,8 @@ func TestAccessTokenScope_Normalize(t *testing.T) {
{"all", "all", nil},
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", nil},
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil},
{"write:repository,write:commitstatus", "write:repository", nil},
{"read:repository,write:commitstatus", "read:repository,write:commitstatus", nil},
}
for _, scope := range GetAccessTokenCategories() {
+1 -1
View File
@@ -35,7 +35,7 @@ type AccessTokenList []*AccessToken
type CreateAccessTokenOption struct {
// required: true
Name string `json:"name" binding:"Required"`
// example: ["all", "read:activitypub","read:issue", "write:misc", "read:notification", "read:organization", "read:package", "read:repository", "read:user"]
// example: ["all", "read:activitypub", "write:commitstatus", "read:issue", "write:misc", "read:notification", "read:organization", "read:package", "read:repository", "read:user"]
Scopes []string `json:"scopes"`
}
+2 -2
View File
@@ -313,7 +313,7 @@ func (r *Router) Combo(pattern string, h ...any) *Combo {
// It is only designed to resolve some special cases which chi router can't handle.
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
func (r *Router) PathGroup(pattern string, fn func(g *RouterPathGroup), h ...any) {
g := &RouterPathGroup{r: r, pathParam: "*"}
g := &RouterPathGroup{r: r, pathParam: "*", middlewares: h} // run h only on a match, unmatched methods and paths also fall back here
fn(g)
r.Any(pattern, append(h, g.ServeHTTP)...)
r.Any(pattern, g.ServeHTTP)
}
+8 -7
View File
@@ -17,10 +17,11 @@ import (
)
type RouterPathGroup struct {
r *Router
pathParam string
matchers []*routerPathMatcher
unescape bool
r *Router
pathParam string
matchers []*routerPathMatcher
unescape bool
middlewares []any
}
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
@@ -55,7 +56,7 @@ func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) {
}
func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupPattern, h ...any) {
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, g.middlewares, h...))
}
func (g *RouterPathGroup) UseUnescapedPath() {
@@ -127,8 +128,8 @@ func isValidMethod(name string) bool {
return false
}
func newRouterPathMatcher(methods string, patternRegexp *RouterPathGroupPattern, h ...any) *routerPathMatcher {
middlewares, handlerFunc, hasPreMiddlewares := wrapMiddlewareAndHandler(nil, patternRegexp.middlewares, h)
func newRouterPathMatcher(methods string, patternRegexp *RouterPathGroupPattern, uses []any, h ...any) *routerPathMatcher {
middlewares, handlerFunc, hasPreMiddlewares := wrapMiddlewareAndHandler(uses, patternRegexp.middlewares, h)
if hasPreMiddlewares {
panic("pre-middlewares are not supported in router path matcher")
}
+3 -3
View File
@@ -102,7 +102,7 @@ func TestPathProcessor(t *testing.T) {
testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
chiCtx := chi.NewRouteContext()
chiCtx.RouteMethod = "GET"
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
p := newRouterPathMatcher("GET", patternRegexp(pattern), nil, http.NotFound)
shouldProcess := expectedPathParams != nil
if unescape {
uri, _ = url.PathUnescape(uri)
@@ -236,12 +236,12 @@ func TestRouter(t *testing.T) {
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{
method: "GET",
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
handlerMarks: []string{"s1", "not-found:/api/v1"},
handlerMarks: []string{"not-found:/api/v1"},
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
pathParams: map[string]string{"*": "d1/d2/fn", "dir": "d1/d2", "file": "fn", "reponame": "the-repo", "username": "the-user"},
handlerMarks: []string{"s1"},
})
+1 -1
View File
@@ -853,7 +853,7 @@
"settings.permission_anonymous_read": "Anonymous Read",
"settings.permission_everyone_read": "Everyone Read",
"settings.permission_everyone_write": "Everyone Write",
"settings.access_token_desc": "Selected token permissions limit authorization only to the corresponding <a %s>API</a> routes. Read the <a %s>documentation</a> for more information.",
"settings.access_token_desc": "Selected token permissions limit authorization only to the corresponding <a %s>API</a> routes. Repository permissions include commit status permissions. Read the <a %s>documentation</a> for more information.",
"settings.at_least_one_permission": "You must select at least one permission to create a token",
"settings.permissions_list": "Permissions:",
"settings.manage_oauth2_applications": "Manage OAuth2 Applications",
+19 -11
View File
@@ -1489,23 +1489,13 @@ func Routes() *web.Router {
})
m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
m.Group("/statuses", func() { // "/statuses/{sha}" only accepts commit ID
m.Combo("/{sha}").Get(repo.GetCommitStatuses).
Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)
}, reqRepoReader(unit.TypeCode))
m.Group("/commits", func() {
m.Group("", func() {
m.Get("", repo.GetAllCommits)
m.Get("/{sha}", repo.GetSingleCommit) // GitHub-compatible endpoint
m.Get("/{sha}.{diffType:diff|patch}", repo.DownloadCommitDiffOrPatch)
}, context.ReferencesGitRepo(true))
m.PathGroup("/*", func(g *web.RouterPathGroup) {
// Mis-configured reverse proxy might decode the `%2F` to slash ahead, so we need to support both formats (escaped, unescaped) here.
// It also matches GitHub's behavior
g.MatchPath("GET", "/<ref:*>/status", repo.GetCombinedCommitStatusByRef)
g.MatchPath("GET", "/<ref:*>/statuses", repo.GetCommitStatusesByRef)
g.MatchPath("GET", "/<sha>/pull", repo.GetCommitPullRequest)
})
m.Get("/{sha}/pull", repo.GetCommitPullRequest)
}, reqRepoReader(unit.TypeCode))
m.Group("/git", func() {
m.Group("/commits", func() {
@@ -1566,6 +1556,24 @@ func Routes() *web.Router {
}, repoAssignment())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
m.Group("/repos/{username}/{reponame}", func() { // outside "/repos" so tokens need only the commitstatus scope
middlewares := []any{
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryCommitStatus),
repoAssignment(), reqRepoReader(unit.TypeCode),
}
// "/.../statuses/{sha}" only accepts commit ID
m.Combo("/statuses/{sha}", middlewares...).
Get(repo.GetCommitStatuses).
Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)
// Mis-configured reverse proxy might decode the `%2F` to slash ahead, so we need to support both formats (escaped, unescaped) here.
// It also matches GitHub's behavior
m.PathGroup("/commits/*", func(g *web.RouterPathGroup) {
g.MatchPath("GET", "/<ref:*>/status", repo.GetCombinedCommitStatusByRef)
g.MatchPath("GET", "/<ref:*>/statuses", repo.GetCommitStatusesByRef)
}, middlewares...)
})
// Artifacts direct download endpoint authenticates via signed url
// it is protected by the "sig" parameter (to help to access private repo), so no need to use other middlewares
m.Get("/repos/{username}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", repo.DownloadArtifactRaw)
+1
View File
@@ -3663,6 +3663,7 @@
"example": [
"all",
"read:activitypub",
"write:commitstatus",
"read:issue",
"write:misc",
"read:notification",
+1
View File
@@ -26124,6 +26124,7 @@
"example": [
"all",
"read:activitypub",
"write:commitstatus",
"read:issue",
"write:misc",
"read:notification",
+33
View File
@@ -204,6 +204,39 @@ func TestRepoCommitsWithStatus(t *testing.T) {
assert.NotEmpty(t, statuses)
assert.NotEmpty(t, status.Statuses)
})
t.Run("TokenScopes", func(t *testing.T) {
commitIDs := map[string]string{"repo1": "65f1bf27bc3bf70f64657658635e66094edbcb4d", "repo2": "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"}
for _, test := range []struct {
name string
userID int64
reponame string
scope auth_model.AccessTokenScope
getCode, postCode int
}{
{"WriteCommitStatus", 2, "repo1", auth_model.AccessTokenScopeWriteCommitStatus, http.StatusOK, http.StatusCreated},
{"ReadCommitStatus", 2, "repo1", auth_model.AccessTokenScopeReadCommitStatus, http.StatusOK, http.StatusForbidden},
{"WriteRepository", 2, "repo1", auth_model.AccessTokenScopeWriteRepository, http.StatusOK, http.StatusCreated},
{"ReadRepository", 2, "repo1", auth_model.AccessTokenScopeReadRepository, http.StatusOK, http.StatusForbidden},
{"UnrelatedScope", 2, "repo1", auth_model.AccessTokenScopeWriteIssue, http.StatusForbidden, http.StatusForbidden},
{"NonWriter", 4, "repo1", auth_model.AccessTokenScopeWriteCommitStatus, http.StatusOK, http.StatusForbidden},
{"PublicOnlyPrivateRepo", 2, "repo2", "public-only,write:commitstatus", http.StatusNotFound, http.StatusNotFound},
} {
t.Run(test.name, func(t *testing.T) {
token := &auth_model.AccessToken{UID: test.userID, Name: test.name, Scope: test.scope}
require.NoError(t, auth_model.NewAccessToken(t.Context(), token))
repoLink, commitID := "/api/v1/repos/user2/"+test.reponame, commitIDs[test.reponame]
for _, link := range []string{"/statuses/" + commitID, "/commits/master/status", "/commits/master/statuses"} {
MakeRequest(t, NewRequest(t, "GET", repoLink+link).AddTokenAuth(token.Token), test.getCode)
}
MakeRequest(t, NewRequestWithJSON(t, "POST", repoLink+"/statuses/"+commitID, api.CreateStatusOption{State: commitstatus.CommitStatusSuccess}).AddTokenAuth(token.Token), test.postCode)
})
}
t.Run("UnmatchedCommitPathSkipsCommitStatusScope", func(t *testing.T) {
token := &auth_model.AccessToken{UID: 2, Name: "UnmatchedCommitPath", Scope: auth_model.AccessTokenScopeWriteIssue}
require.NoError(t, auth_model.NewAccessToken(t.Context(), token))
MakeRequest(t, NewRequest(t, "POST", "/api/v1/repos/user2/repo1/commits/"+commitIDs["repo1"]+"/pull").AddTokenAuth(token.Token), http.StatusNotFound)
})
})
}
func TestRepoCommitsStatus(t *testing.T) {