diff --git a/models/auth/access_token_scope.go b/models/auth/access_token_scope.go index 3eae19b2a5..cf1ab63b67 100644 --- a/models/auth/access_token_scope.go +++ b/models/auth/access_token_scope.go @@ -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 @@ -82,6 +84,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. @@ -93,7 +98,7 @@ const ( accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits | accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits | accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits | - accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits + accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits | accessTokenScopeWriteCommitStatusBits accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota @@ -118,12 +123,15 @@ const ( accessTokenScopeReadIssueBits accessTokenScopeBitmap = 1 << iota accessTokenScopeWriteIssueBits accessTokenScopeBitmap = 1< 64 scopes, // refactoring the whole implementation in this file (and only this file) is needed. @@ -142,6 +150,7 @@ var allAccessTokenScopes = []AccessTokenScope{ AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue, AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository, AccessTokenScopeWriteUser, AccessTokenScopeReadUser, + AccessTokenScopeWriteCommitStatus, AccessTokenScopeReadCommitStatus, } // allAccessTokenScopeBits contains all access token scopes. @@ -166,6 +175,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 @@ -180,6 +191,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue, AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository, AccessTokenScopeCategoryUser: AccessTokenScopeReadUser, + AccessTokenScopeCategoryCommitStatus: AccessTokenScopeReadCommitStatus, }, Write: { AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub, @@ -191,6 +203,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue, AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository, AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser, + AccessTokenScopeCategoryCommitStatus: AccessTokenScopeWriteCommitStatus, }, } diff --git a/models/auth/access_token_scope_test.go b/models/auth/access_token_scope_test.go index b93c25528f..b753da3e71 100644 --- a/models/auth/access_token_scope_test.go +++ b/models/auth/access_token_scope_test.go @@ -17,13 +17,13 @@ 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()) tests := []scopeTestNormalize{ {"", "", nil}, {"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil}, {"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:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:commitstatus", "all", nil}, + {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:commitstatus,public-only", "public-only,all", nil}, } for _, scope := range GetAccessTokenCategories() { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..737317e197 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1373,10 +1373,6 @@ 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.Get("", context.ReferencesGitRepo(), repo.GetAllCommits) m.PathGroup("/*", func(g *web.RouterPathGroup) { @@ -1446,6 +1442,12 @@ func Routes() *web.Router { }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) + // Commit status can be created by write:commitstatus or write:repository + m.Group("/repos/{username}/{reponame}/statuses", func() { // "/statuses/{sha}" only accepts commit ID + m.Combo("/{sha}").Get(repo.GetCommitStatuses). + Post(reqToken(), bind(api.CreateStatusOption{}), repo.NewCommitStatus) + }, reqRepoReader(unit.TypeCode), repoAssignment(), checkTokenPublicOnly(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryCommitStatus)) + // 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) diff --git a/verify35383.py b/verify35383.py new file mode 100755 index 0000000000..232cde6677 --- /dev/null +++ b/verify35383.py @@ -0,0 +1,88 @@ +# usage: uv run pytest -v verify35383.py + +import os +import time +import requests +import pytest + +GITEA_URL = os.environ["GITEA_URL"].rstrip("/") +OWNER = os.environ["OWNER"] +REPO = os.environ["REPO"] +COMMIT_SHA = os.environ["COMMIT_SHA"] + +STATUS_URL = f"{GITEA_URL}/api/v1/repos/{OWNER}/{REPO}/statuses/{COMMIT_SHA}" + +TOKENS = { + "repo-write": { + "token": os.environ["TOKEN_REPO_WRITE"], + "can_write": True, + "can_read": True, + }, + "repo-read+commitstatus-write": { + "token": os.environ["TOKEN_STATUS_WRITE"], + "can_write": True, + "can_read": True, + }, + "repo-read-only": { + "token": os.environ["TOKEN_REPO_READ"], + "can_write": False, + "can_read": True, + }, +} + + +def auth_headers(token: str) -> dict: + return { + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + +@pytest.mark.parametrize("name,cfg", TOKENS.items()) +def test_commit_status_write_permission(name: str, cfg: dict) -> None: + context = f"perm-test-{name}-{int(time.time())}" + + payload = { + "state": "success", + "context": context, + "description": "Permission verification test", + "target_url": "https://example.com", + } + + response = requests.post( + STATUS_URL, + json=payload, + headers=auth_headers(cfg["token"]), + timeout=10, + ) + + if cfg["can_write"]: + assert response.status_code == 201, response.text + body = response.json() + assert body["status"] == "success" + assert body["context"] == context + else: + assert response.status_code == 403 + + +@pytest.mark.parametrize("name,cfg", TOKENS.items()) +def test_commit_status_read_permission(name: str, cfg: dict) -> None: + response = requests.get( + STATUS_URL, + headers=auth_headers(cfg["token"]), + timeout=10, + ) + + if cfg["can_read"]: + assert response.status_code == 200, response.text + body = response.json() + assert isinstance(body, list) + + if body: + status = body[0] + assert "status" in status + assert "context" in status + assert "created_at" in status + else: + assert response.status_code in (401, 403)