Skip to content

Commit de484e8

Browse files
authored
Support scoped access tokens (#20908)
This PR adds the support for scopes of access tokens, mimicking the design of GitHub OAuth scopes. The changes of the core logic are in `models/auth` that `AccessToken` struct will have a `Scope` field. The normalized (no duplication of scope), comma-separated scope string will be stored in `access_token` table in the database. In `services/auth`, the scope will be stored in context, which will be used by `reqToken` middleware in API calls. Only OAuth2 tokens will have granular token scopes, while others like BasicAuth will default to scope `all`. A large amount of work happens in `routers/api/v1/api.go` and the corresponding `tests/integration` tests, that is adding necessary scopes to each of the API calls as they fit. - [x] Add `Scope` field to `AccessToken` - [x] Add access control to all API endpoints - [x] Update frontend & backend for when creating tokens - [x] Add a database migration for `scope` column (enable 'all' access to past tokens) I'm aiming to complete it before Gitea 1.19 release. Fixes #4300
1 parent db2286b commit de484e8

File tree

79 files changed

+1220
-448
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+1220
-448
lines changed

docs/content/doc/developers/oauth2-provider.en-us.md

+35-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,41 @@ To use the Authorization Code Grant as a third party application it is required
4242

4343
## Scopes
4444

45-
Currently Gitea does not support scopes (see [#4300](https://github.com/go-gitea/gitea/issues/4300)) and all third party applications will be granted access to all resources of the user and their organizations.
45+
Gitea supports the following scopes for tokens:
46+
47+
| Name | Description |
48+
| ---- | ----------- |
49+
| **(no scope)** | Grants read-only access to public user profile and public repositories. |
50+
| **repo** | Full control over all repositories. |
51+
|     **repo:status** | Grants read/write access to commit status in all repositories. |
52+
|     **public_repo** | Grants read/write access to public repositories only. |
53+
| **admin:repo_hook** | Grants access to repository hooks of all repositories. This is included in the `repo` scope. |
54+
|     **write:repo_hook** | Grants read/write access to repository hooks |
55+
|     **read:repo_hook** | Grants read-only access to repository hooks |
56+
| **admin:org** | Grants full access to organization settings |
57+
|     **write:org** | Grants read/write access to organization settings |
58+
|     **read:org** | Grants read-only access to organization settings |
59+
| **admin:public_key** | Grants full access for managing public keys |
60+
|     **write:public_key** | Grant read/write access to public keys |
61+
|     **read:public_key** | Grant read-only access to public keys |
62+
| **admin:org_hook** | Grants full access to organizational-level hooks |
63+
| **notification** | Grants full access to notifications |
64+
| **user** | Grants full access to user profile info |
65+
|     **read:user** | Grants read access to user's profile |
66+
|     **user:email** | Grants read access to user's email addresses |
67+
|     **user:follow** | Grants access to follow/un-follow a user |
68+
| **delete_repo** | Grants access to delete repositories as an admin |
69+
| **package** | Grants full access to hosted packages |
70+
|     **write:package** | Grants read/write access to packages |
71+
|     **read:package** | Grants read access to packages |
72+
|     **delete:package** | Grants delete access to packages |
73+
| **admin:gpg_key** | Grants full access for managing GPG keys |
74+
|     **write:gpg_key** | Grants read/write access to GPG keys |
75+
|     **read:gpg_key** | Grants read-only access to GPG keys |
76+
| **admin:application** | Grants full access to manage applications |
77+
|     **write:application** | Grants read/write access for managing applications |
78+
|     **read:application** | Grants read access for managing applications |
79+
| **sudo** | Allows to perform actions as the site admin. |
4680

4781
## Client types
4882

models/auth/token.go

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type AccessToken struct {
6565
TokenHash string `xorm:"UNIQUE"` // sha256 of token
6666
TokenSalt string
6767
TokenLastEight string `xorm:"INDEX token_last_eight"`
68+
Scope AccessTokenScope
6869

6970
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
7071
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`

models/auth/token_scope.go

+251
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package auth
5+
6+
import (
7+
"fmt"
8+
"strings"
9+
)
10+
11+
// AccessTokenScope represents the scope for an access token.
12+
type AccessTokenScope string
13+
14+
const (
15+
AccessTokenScopeAll AccessTokenScope = "all"
16+
17+
AccessTokenScopeRepo AccessTokenScope = "repo"
18+
AccessTokenScopeRepoStatus AccessTokenScope = "repo:status"
19+
AccessTokenScopePublicRepo AccessTokenScope = "public_repo"
20+
21+
AccessTokenScopeAdminOrg AccessTokenScope = "admin:org"
22+
AccessTokenScopeWriteOrg AccessTokenScope = "write:org"
23+
AccessTokenScopeReadOrg AccessTokenScope = "read:org"
24+
25+
AccessTokenScopeAdminPublicKey AccessTokenScope = "admin:public_key"
26+
AccessTokenScopeWritePublicKey AccessTokenScope = "write:public_key"
27+
AccessTokenScopeReadPublicKey AccessTokenScope = "read:public_key"
28+
29+
AccessTokenScopeAdminRepoHook AccessTokenScope = "admin:repo_hook"
30+
AccessTokenScopeWriteRepoHook AccessTokenScope = "write:repo_hook"
31+
AccessTokenScopeReadRepoHook AccessTokenScope = "read:repo_hook"
32+
33+
AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook"
34+
35+
AccessTokenScopeNotification AccessTokenScope = "notification"
36+
37+
AccessTokenScopeUser AccessTokenScope = "user"
38+
AccessTokenScopeReadUser AccessTokenScope = "read:user"
39+
AccessTokenScopeUserEmail AccessTokenScope = "user:email"
40+
AccessTokenScopeUserFollow AccessTokenScope = "user:follow"
41+
42+
AccessTokenScopeDeleteRepo AccessTokenScope = "delete_repo"
43+
44+
AccessTokenScopePackage AccessTokenScope = "package"
45+
AccessTokenScopeWritePackage AccessTokenScope = "write:package"
46+
AccessTokenScopeReadPackage AccessTokenScope = "read:package"
47+
AccessTokenScopeDeletePackage AccessTokenScope = "delete:package"
48+
49+
AccessTokenScopeAdminGPGKey AccessTokenScope = "admin:gpg_key"
50+
AccessTokenScopeWriteGPGKey AccessTokenScope = "write:gpg_key"
51+
AccessTokenScopeReadGPGKey AccessTokenScope = "read:gpg_key"
52+
53+
AccessTokenScopeAdminApplication AccessTokenScope = "admin:application"
54+
AccessTokenScopeWriteApplication AccessTokenScope = "write:application"
55+
AccessTokenScopeReadApplication AccessTokenScope = "read:application"
56+
57+
AccessTokenScopeSudo AccessTokenScope = "sudo"
58+
)
59+
60+
// AccessTokenScopeBitmap represents a bitmap of access token scopes.
61+
type AccessTokenScopeBitmap uint64
62+
63+
// Bitmap of each scope, including the child scopes.
64+
const (
65+
// AccessTokenScopeAllBits is the bitmap of all access token scopes, except `sudo`.
66+
AccessTokenScopeAllBits AccessTokenScopeBitmap = AccessTokenScopeRepoBits |
67+
AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits |
68+
AccessTokenScopeNotificationBits | AccessTokenScopeUserBits | AccessTokenScopeDeleteRepoBits |
69+
AccessTokenScopePackageBits | AccessTokenScopeAdminGPGKeyBits | AccessTokenScopeAdminApplicationBits
70+
71+
AccessTokenScopeRepoBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeRepoStatusBits | AccessTokenScopePublicRepoBits | AccessTokenScopeAdminRepoHookBits
72+
AccessTokenScopeRepoStatusBits AccessTokenScopeBitmap = 1 << iota
73+
AccessTokenScopePublicRepoBits AccessTokenScopeBitmap = 1 << iota
74+
75+
AccessTokenScopeAdminOrgBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteOrgBits
76+
AccessTokenScopeWriteOrgBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadOrgBits
77+
AccessTokenScopeReadOrgBits AccessTokenScopeBitmap = 1 << iota
78+
79+
AccessTokenScopeAdminPublicKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePublicKeyBits
80+
AccessTokenScopeWritePublicKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPublicKeyBits
81+
AccessTokenScopeReadPublicKeyBits AccessTokenScopeBitmap = 1 << iota
82+
83+
AccessTokenScopeAdminRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteRepoHookBits
84+
AccessTokenScopeWriteRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadRepoHookBits
85+
AccessTokenScopeReadRepoHookBits AccessTokenScopeBitmap = 1 << iota
86+
87+
AccessTokenScopeAdminOrgHookBits AccessTokenScopeBitmap = 1 << iota
88+
89+
AccessTokenScopeNotificationBits AccessTokenScopeBitmap = 1 << iota
90+
91+
AccessTokenScopeUserBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadUserBits | AccessTokenScopeUserEmailBits | AccessTokenScopeUserFollowBits
92+
AccessTokenScopeReadUserBits AccessTokenScopeBitmap = 1 << iota
93+
AccessTokenScopeUserEmailBits AccessTokenScopeBitmap = 1 << iota
94+
AccessTokenScopeUserFollowBits AccessTokenScopeBitmap = 1 << iota
95+
96+
AccessTokenScopeDeleteRepoBits AccessTokenScopeBitmap = 1 << iota
97+
98+
AccessTokenScopePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePackageBits | AccessTokenScopeDeletePackageBits
99+
AccessTokenScopeWritePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPackageBits
100+
AccessTokenScopeReadPackageBits AccessTokenScopeBitmap = 1 << iota
101+
AccessTokenScopeDeletePackageBits AccessTokenScopeBitmap = 1 << iota
102+
103+
AccessTokenScopeAdminGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteGPGKeyBits
104+
AccessTokenScopeWriteGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadGPGKeyBits
105+
AccessTokenScopeReadGPGKeyBits AccessTokenScopeBitmap = 1 << iota
106+
107+
AccessTokenScopeAdminApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteApplicationBits
108+
AccessTokenScopeWriteApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadApplicationBits
109+
AccessTokenScopeReadApplicationBits AccessTokenScopeBitmap = 1 << iota
110+
111+
AccessTokenScopeSudoBits AccessTokenScopeBitmap = 1 << iota
112+
113+
// The current implementation only supports up to 64 token scopes.
114+
// If we need to support > 64 scopes,
115+
// refactoring the whole implementation in this file (and only this file) is needed.
116+
)
117+
118+
// allAccessTokenScopes contains all access token scopes.
119+
// The order is important: parent scope must precedes child scopes.
120+
var allAccessTokenScopes = []AccessTokenScope{
121+
AccessTokenScopeRepo, AccessTokenScopeRepoStatus, AccessTokenScopePublicRepo,
122+
AccessTokenScopeAdminOrg, AccessTokenScopeWriteOrg, AccessTokenScopeReadOrg,
123+
AccessTokenScopeAdminPublicKey, AccessTokenScopeWritePublicKey, AccessTokenScopeReadPublicKey,
124+
AccessTokenScopeAdminRepoHook, AccessTokenScopeWriteRepoHook, AccessTokenScopeReadRepoHook,
125+
AccessTokenScopeAdminOrgHook,
126+
AccessTokenScopeNotification,
127+
AccessTokenScopeUser, AccessTokenScopeReadUser, AccessTokenScopeUserEmail, AccessTokenScopeUserFollow,
128+
AccessTokenScopeDeleteRepo,
129+
AccessTokenScopePackage, AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, AccessTokenScopeDeletePackage,
130+
AccessTokenScopeAdminGPGKey, AccessTokenScopeWriteGPGKey, AccessTokenScopeReadGPGKey,
131+
AccessTokenScopeAdminApplication, AccessTokenScopeWriteApplication, AccessTokenScopeReadApplication,
132+
AccessTokenScopeSudo,
133+
}
134+
135+
// allAccessTokenScopeBits contains all access token scopes.
136+
var allAccessTokenScopeBits = map[AccessTokenScope]AccessTokenScopeBitmap{
137+
AccessTokenScopeRepo: AccessTokenScopeRepoBits,
138+
AccessTokenScopeRepoStatus: AccessTokenScopeRepoStatusBits,
139+
AccessTokenScopePublicRepo: AccessTokenScopePublicRepoBits,
140+
AccessTokenScopeAdminOrg: AccessTokenScopeAdminOrgBits,
141+
AccessTokenScopeWriteOrg: AccessTokenScopeWriteOrgBits,
142+
AccessTokenScopeReadOrg: AccessTokenScopeReadOrgBits,
143+
AccessTokenScopeAdminPublicKey: AccessTokenScopeAdminPublicKeyBits,
144+
AccessTokenScopeWritePublicKey: AccessTokenScopeWritePublicKeyBits,
145+
AccessTokenScopeReadPublicKey: AccessTokenScopeReadPublicKeyBits,
146+
AccessTokenScopeAdminRepoHook: AccessTokenScopeAdminRepoHookBits,
147+
AccessTokenScopeWriteRepoHook: AccessTokenScopeWriteRepoHookBits,
148+
AccessTokenScopeReadRepoHook: AccessTokenScopeReadRepoHookBits,
149+
AccessTokenScopeAdminOrgHook: AccessTokenScopeAdminOrgHookBits,
150+
AccessTokenScopeNotification: AccessTokenScopeNotificationBits,
151+
AccessTokenScopeUser: AccessTokenScopeUserBits,
152+
AccessTokenScopeReadUser: AccessTokenScopeReadUserBits,
153+
AccessTokenScopeUserEmail: AccessTokenScopeUserEmailBits,
154+
AccessTokenScopeUserFollow: AccessTokenScopeUserFollowBits,
155+
AccessTokenScopeDeleteRepo: AccessTokenScopeDeleteRepoBits,
156+
AccessTokenScopePackage: AccessTokenScopePackageBits,
157+
AccessTokenScopeWritePackage: AccessTokenScopeWritePackageBits,
158+
AccessTokenScopeReadPackage: AccessTokenScopeReadPackageBits,
159+
AccessTokenScopeDeletePackage: AccessTokenScopeDeletePackageBits,
160+
AccessTokenScopeAdminGPGKey: AccessTokenScopeAdminGPGKeyBits,
161+
AccessTokenScopeWriteGPGKey: AccessTokenScopeWriteGPGKeyBits,
162+
AccessTokenScopeReadGPGKey: AccessTokenScopeReadGPGKeyBits,
163+
AccessTokenScopeAdminApplication: AccessTokenScopeAdminApplicationBits,
164+
AccessTokenScopeWriteApplication: AccessTokenScopeWriteApplicationBits,
165+
AccessTokenScopeReadApplication: AccessTokenScopeReadApplicationBits,
166+
AccessTokenScopeSudo: AccessTokenScopeSudoBits,
167+
}
168+
169+
// Parse parses the scope string into a bitmap, thus removing possible duplicates.
170+
func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) {
171+
list := strings.Split(string(s), ",")
172+
173+
var bitmap AccessTokenScopeBitmap
174+
for _, v := range list {
175+
singleScope := AccessTokenScope(v)
176+
if singleScope == "" {
177+
continue
178+
}
179+
if singleScope == AccessTokenScopeAll {
180+
bitmap |= AccessTokenScopeAllBits
181+
continue
182+
}
183+
184+
bits, ok := allAccessTokenScopeBits[singleScope]
185+
if !ok {
186+
return 0, fmt.Errorf("invalid access token scope: %s", singleScope)
187+
}
188+
bitmap |= bits
189+
}
190+
return bitmap, nil
191+
}
192+
193+
// Normalize returns a normalized scope string without any duplicates.
194+
func (s AccessTokenScope) Normalize() (AccessTokenScope, error) {
195+
bitmap, err := s.Parse()
196+
if err != nil {
197+
return "", err
198+
}
199+
200+
return bitmap.ToScope(), nil
201+
}
202+
203+
// HasScope returns true if the string has the given scope
204+
func (s AccessTokenScope) HasScope(scope AccessTokenScope) (bool, error) {
205+
bitmap, err := s.Parse()
206+
if err != nil {
207+
return false, err
208+
}
209+
210+
return bitmap.HasScope(scope)
211+
}
212+
213+
// HasScope returns true if the string has the given scope
214+
func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, error) {
215+
expectedBits, ok := allAccessTokenScopeBits[scope]
216+
if !ok {
217+
return false, fmt.Errorf("invalid access token scope: %s", scope)
218+
}
219+
220+
return bitmap&expectedBits == expectedBits, nil
221+
}
222+
223+
// ToScope returns a normalized scope string without any duplicates.
224+
func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope {
225+
var scopes []string
226+
227+
// iterate over all scopes, and reconstruct the bitmap
228+
// if the reconstructed bitmap doesn't change, then the scope is already included
229+
var reconstruct AccessTokenScopeBitmap
230+
231+
for _, singleScope := range allAccessTokenScopes {
232+
// no need for error checking here, since we know the scope is valid
233+
if ok, _ := bitmap.HasScope(singleScope); ok {
234+
current := reconstruct | allAccessTokenScopeBits[singleScope]
235+
if current == reconstruct {
236+
continue
237+
}
238+
239+
reconstruct = current
240+
scopes = append(scopes, string(singleScope))
241+
}
242+
}
243+
244+
scope := AccessTokenScope(strings.Join(scopes, ","))
245+
scope = AccessTokenScope(strings.ReplaceAll(
246+
string(scope),
247+
"repo,admin:org,admin:public_key,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application",
248+
"all",
249+
))
250+
return scope
251+
}

models/auth/token_scope_test.go

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package auth
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestAccessTokenScope_Normalize(t *testing.T) {
13+
tests := []struct {
14+
in AccessTokenScope
15+
out AccessTokenScope
16+
err error
17+
}{
18+
{"", "", nil},
19+
{"repo", "repo", nil},
20+
{"repo,repo:status", "repo", nil},
21+
{"repo,public_repo", "repo", nil},
22+
{"admin:public_key,write:public_key", "admin:public_key", nil},
23+
{"admin:public_key,read:public_key", "admin:public_key", nil},
24+
{"write:public_key,read:public_key", "write:public_key", nil}, // read is include in write
25+
{"admin:repo_hook,write:repo_hook", "admin:repo_hook", nil},
26+
{"admin:repo_hook,read:repo_hook", "admin:repo_hook", nil},
27+
{"repo,admin:repo_hook,read:repo_hook", "repo", nil}, // admin:repo_hook is a child scope of repo
28+
{"repo,read:repo_hook", "repo", nil}, // read:repo_hook is a child scope of repo
29+
{"user", "user", nil},
30+
{"user,read:user", "user", nil},
31+
{"user,admin:org,write:org", "admin:org,user", nil},
32+
{"admin:org,write:org,user", "admin:org,user", nil},
33+
{"package", "package", nil},
34+
{"package,write:package", "package", nil},
35+
{"package,write:package,delete:package", "package", nil},
36+
{"write:package,read:package", "write:package", nil}, // read is include in write
37+
{"write:package,delete:package", "write:package,delete:package", nil}, // write and delete are not include in each other
38+
{"admin:gpg_key", "admin:gpg_key", nil},
39+
{"admin:gpg_key,write:gpg_key", "admin:gpg_key", nil},
40+
{"admin:gpg_key,write:gpg_key,user", "user,admin:gpg_key", nil},
41+
{"admin:application,write:application,user", "user,admin:application", nil},
42+
{"all", "all", nil},
43+
{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", "all", nil},
44+
{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application,sudo", "all,sudo", nil},
45+
}
46+
47+
for _, test := range tests {
48+
t.Run(string(test.in), func(t *testing.T) {
49+
scope, err := test.in.Normalize()
50+
assert.Equal(t, test.out, scope)
51+
assert.Equal(t, test.err, err)
52+
})
53+
}
54+
}
55+
56+
func TestAccessTokenScope_HasScope(t *testing.T) {
57+
tests := []struct {
58+
in AccessTokenScope
59+
scope AccessTokenScope
60+
out bool
61+
err error
62+
}{
63+
{"repo", "repo", true, nil},
64+
{"repo", "repo:status", true, nil},
65+
{"repo", "public_repo", true, nil},
66+
{"repo", "admin:org", false, nil},
67+
{"repo", "admin:public_key", false, nil},
68+
{"repo:status", "repo", false, nil},
69+
{"repo:status", "public_repo", false, nil},
70+
{"admin:org", "write:org", true, nil},
71+
{"admin:org", "read:org", true, nil},
72+
{"admin:org", "admin:org", true, nil},
73+
{"user", "read:user", true, nil},
74+
{"package", "write:package", true, nil},
75+
}
76+
77+
for _, test := range tests {
78+
t.Run(string(test.in), func(t *testing.T) {
79+
scope, err := test.in.HasScope(test.scope)
80+
assert.Equal(t, test.out, scope)
81+
assert.Equal(t, test.err, err)
82+
})
83+
}
84+
}

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,8 @@ var migrations = []Migration{
451451
NewMigration("Drop ForeignReference table", v1_19.DropForeignReferenceTable),
452452
// v238 -> v239
453453
NewMigration("Add updated unix to LFSMetaObject", v1_19.AddUpdatedUnixToLFSMetaObject),
454+
// v239 -> v240
455+
NewMigration("Add scope for access_token", v1_19.AddScopeForAccessTokens),
454456
}
455457

456458
// GetCurrentDBVersion returns the current db version

0 commit comments

Comments
 (0)