-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
79 changed files
with
1,220 additions
and
448 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,251 @@ | ||
// Copyright 2022 The Gitea Authors. All rights reserved. | ||
// SPDX-License-Identifier: MIT | ||
|
||
package auth | ||
|
||
import ( | ||
"fmt" | ||
"strings" | ||
) | ||
|
||
// AccessTokenScope represents the scope for an access token. | ||
type AccessTokenScope string | ||
|
||
const ( | ||
AccessTokenScopeAll AccessTokenScope = "all" | ||
|
||
AccessTokenScopeRepo AccessTokenScope = "repo" | ||
AccessTokenScopeRepoStatus AccessTokenScope = "repo:status" | ||
AccessTokenScopePublicRepo AccessTokenScope = "public_repo" | ||
|
||
AccessTokenScopeAdminOrg AccessTokenScope = "admin:org" | ||
AccessTokenScopeWriteOrg AccessTokenScope = "write:org" | ||
AccessTokenScopeReadOrg AccessTokenScope = "read:org" | ||
|
||
AccessTokenScopeAdminPublicKey AccessTokenScope = "admin:public_key" | ||
AccessTokenScopeWritePublicKey AccessTokenScope = "write:public_key" | ||
AccessTokenScopeReadPublicKey AccessTokenScope = "read:public_key" | ||
|
||
AccessTokenScopeAdminRepoHook AccessTokenScope = "admin:repo_hook" | ||
AccessTokenScopeWriteRepoHook AccessTokenScope = "write:repo_hook" | ||
AccessTokenScopeReadRepoHook AccessTokenScope = "read:repo_hook" | ||
|
||
AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook" | ||
|
||
AccessTokenScopeNotification AccessTokenScope = "notification" | ||
|
||
AccessTokenScopeUser AccessTokenScope = "user" | ||
AccessTokenScopeReadUser AccessTokenScope = "read:user" | ||
AccessTokenScopeUserEmail AccessTokenScope = "user:email" | ||
AccessTokenScopeUserFollow AccessTokenScope = "user:follow" | ||
|
||
AccessTokenScopeDeleteRepo AccessTokenScope = "delete_repo" | ||
|
||
AccessTokenScopePackage AccessTokenScope = "package" | ||
AccessTokenScopeWritePackage AccessTokenScope = "write:package" | ||
AccessTokenScopeReadPackage AccessTokenScope = "read:package" | ||
AccessTokenScopeDeletePackage AccessTokenScope = "delete:package" | ||
|
||
AccessTokenScopeAdminGPGKey AccessTokenScope = "admin:gpg_key" | ||
AccessTokenScopeWriteGPGKey AccessTokenScope = "write:gpg_key" | ||
AccessTokenScopeReadGPGKey AccessTokenScope = "read:gpg_key" | ||
|
||
AccessTokenScopeAdminApplication AccessTokenScope = "admin:application" | ||
AccessTokenScopeWriteApplication AccessTokenScope = "write:application" | ||
AccessTokenScopeReadApplication AccessTokenScope = "read:application" | ||
|
||
AccessTokenScopeSudo AccessTokenScope = "sudo" | ||
) | ||
|
||
// AccessTokenScopeBitmap represents a bitmap of access token scopes. | ||
type AccessTokenScopeBitmap uint64 | ||
|
||
// Bitmap of each scope, including the child scopes. | ||
const ( | ||
// AccessTokenScopeAllBits is the bitmap of all access token scopes, except `sudo`. | ||
AccessTokenScopeAllBits AccessTokenScopeBitmap = AccessTokenScopeRepoBits | | ||
AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits | | ||
AccessTokenScopeNotificationBits | AccessTokenScopeUserBits | AccessTokenScopeDeleteRepoBits | | ||
AccessTokenScopePackageBits | AccessTokenScopeAdminGPGKeyBits | AccessTokenScopeAdminApplicationBits | ||
|
||
AccessTokenScopeRepoBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeRepoStatusBits | AccessTokenScopePublicRepoBits | AccessTokenScopeAdminRepoHookBits | ||
AccessTokenScopeRepoStatusBits AccessTokenScopeBitmap = 1 << iota | ||
AccessTokenScopePublicRepoBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeAdminOrgBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteOrgBits | ||
AccessTokenScopeWriteOrgBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadOrgBits | ||
AccessTokenScopeReadOrgBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeAdminPublicKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePublicKeyBits | ||
AccessTokenScopeWritePublicKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPublicKeyBits | ||
AccessTokenScopeReadPublicKeyBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeAdminRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteRepoHookBits | ||
AccessTokenScopeWriteRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadRepoHookBits | ||
AccessTokenScopeReadRepoHookBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeAdminOrgHookBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeNotificationBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeUserBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadUserBits | AccessTokenScopeUserEmailBits | AccessTokenScopeUserFollowBits | ||
AccessTokenScopeReadUserBits AccessTokenScopeBitmap = 1 << iota | ||
AccessTokenScopeUserEmailBits AccessTokenScopeBitmap = 1 << iota | ||
AccessTokenScopeUserFollowBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeDeleteRepoBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePackageBits | AccessTokenScopeDeletePackageBits | ||
AccessTokenScopeWritePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPackageBits | ||
AccessTokenScopeReadPackageBits AccessTokenScopeBitmap = 1 << iota | ||
AccessTokenScopeDeletePackageBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeAdminGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteGPGKeyBits | ||
AccessTokenScopeWriteGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadGPGKeyBits | ||
AccessTokenScopeReadGPGKeyBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeAdminApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteApplicationBits | ||
AccessTokenScopeWriteApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadApplicationBits | ||
AccessTokenScopeReadApplicationBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
AccessTokenScopeSudoBits AccessTokenScopeBitmap = 1 << iota | ||
|
||
// 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. | ||
) | ||
|
||
// allAccessTokenScopes contains all access token scopes. | ||
// The order is important: parent scope must precedes child scopes. | ||
var allAccessTokenScopes = []AccessTokenScope{ | ||
AccessTokenScopeRepo, AccessTokenScopeRepoStatus, AccessTokenScopePublicRepo, | ||
AccessTokenScopeAdminOrg, AccessTokenScopeWriteOrg, AccessTokenScopeReadOrg, | ||
AccessTokenScopeAdminPublicKey, AccessTokenScopeWritePublicKey, AccessTokenScopeReadPublicKey, | ||
AccessTokenScopeAdminRepoHook, AccessTokenScopeWriteRepoHook, AccessTokenScopeReadRepoHook, | ||
AccessTokenScopeAdminOrgHook, | ||
AccessTokenScopeNotification, | ||
AccessTokenScopeUser, AccessTokenScopeReadUser, AccessTokenScopeUserEmail, AccessTokenScopeUserFollow, | ||
AccessTokenScopeDeleteRepo, | ||
AccessTokenScopePackage, AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, AccessTokenScopeDeletePackage, | ||
AccessTokenScopeAdminGPGKey, AccessTokenScopeWriteGPGKey, AccessTokenScopeReadGPGKey, | ||
AccessTokenScopeAdminApplication, AccessTokenScopeWriteApplication, AccessTokenScopeReadApplication, | ||
AccessTokenScopeSudo, | ||
} | ||
|
||
// allAccessTokenScopeBits contains all access token scopes. | ||
var allAccessTokenScopeBits = map[AccessTokenScope]AccessTokenScopeBitmap{ | ||
AccessTokenScopeRepo: AccessTokenScopeRepoBits, | ||
AccessTokenScopeRepoStatus: AccessTokenScopeRepoStatusBits, | ||
AccessTokenScopePublicRepo: AccessTokenScopePublicRepoBits, | ||
AccessTokenScopeAdminOrg: AccessTokenScopeAdminOrgBits, | ||
AccessTokenScopeWriteOrg: AccessTokenScopeWriteOrgBits, | ||
AccessTokenScopeReadOrg: AccessTokenScopeReadOrgBits, | ||
AccessTokenScopeAdminPublicKey: AccessTokenScopeAdminPublicKeyBits, | ||
AccessTokenScopeWritePublicKey: AccessTokenScopeWritePublicKeyBits, | ||
AccessTokenScopeReadPublicKey: AccessTokenScopeReadPublicKeyBits, | ||
AccessTokenScopeAdminRepoHook: AccessTokenScopeAdminRepoHookBits, | ||
AccessTokenScopeWriteRepoHook: AccessTokenScopeWriteRepoHookBits, | ||
AccessTokenScopeReadRepoHook: AccessTokenScopeReadRepoHookBits, | ||
AccessTokenScopeAdminOrgHook: AccessTokenScopeAdminOrgHookBits, | ||
AccessTokenScopeNotification: AccessTokenScopeNotificationBits, | ||
AccessTokenScopeUser: AccessTokenScopeUserBits, | ||
AccessTokenScopeReadUser: AccessTokenScopeReadUserBits, | ||
AccessTokenScopeUserEmail: AccessTokenScopeUserEmailBits, | ||
AccessTokenScopeUserFollow: AccessTokenScopeUserFollowBits, | ||
AccessTokenScopeDeleteRepo: AccessTokenScopeDeleteRepoBits, | ||
AccessTokenScopePackage: AccessTokenScopePackageBits, | ||
AccessTokenScopeWritePackage: AccessTokenScopeWritePackageBits, | ||
AccessTokenScopeReadPackage: AccessTokenScopeReadPackageBits, | ||
AccessTokenScopeDeletePackage: AccessTokenScopeDeletePackageBits, | ||
AccessTokenScopeAdminGPGKey: AccessTokenScopeAdminGPGKeyBits, | ||
AccessTokenScopeWriteGPGKey: AccessTokenScopeWriteGPGKeyBits, | ||
AccessTokenScopeReadGPGKey: AccessTokenScopeReadGPGKeyBits, | ||
AccessTokenScopeAdminApplication: AccessTokenScopeAdminApplicationBits, | ||
AccessTokenScopeWriteApplication: AccessTokenScopeWriteApplicationBits, | ||
AccessTokenScopeReadApplication: AccessTokenScopeReadApplicationBits, | ||
AccessTokenScopeSudo: AccessTokenScopeSudoBits, | ||
} | ||
|
||
// Parse parses the scope string into a bitmap, thus removing possible duplicates. | ||
func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) { | ||
list := strings.Split(string(s), ",") | ||
|
||
var bitmap AccessTokenScopeBitmap | ||
for _, v := range list { | ||
singleScope := AccessTokenScope(v) | ||
if singleScope == "" { | ||
continue | ||
} | ||
if singleScope == AccessTokenScopeAll { | ||
bitmap |= AccessTokenScopeAllBits | ||
continue | ||
} | ||
|
||
bits, ok := allAccessTokenScopeBits[singleScope] | ||
if !ok { | ||
return 0, fmt.Errorf("invalid access token scope: %s", singleScope) | ||
} | ||
bitmap |= bits | ||
} | ||
return bitmap, nil | ||
} | ||
|
||
// Normalize returns a normalized scope string without any duplicates. | ||
func (s AccessTokenScope) Normalize() (AccessTokenScope, error) { | ||
bitmap, err := s.Parse() | ||
if err != nil { | ||
return "", err | ||
} | ||
|
||
return bitmap.ToScope(), nil | ||
} | ||
|
||
// HasScope returns true if the string has the given scope | ||
func (s AccessTokenScope) HasScope(scope AccessTokenScope) (bool, error) { | ||
bitmap, err := s.Parse() | ||
if err != nil { | ||
return false, err | ||
} | ||
|
||
return bitmap.HasScope(scope) | ||
} | ||
|
||
// HasScope returns true if the string has the given scope | ||
func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, error) { | ||
expectedBits, ok := allAccessTokenScopeBits[scope] | ||
if !ok { | ||
return false, fmt.Errorf("invalid access token scope: %s", scope) | ||
} | ||
|
||
return bitmap&expectedBits == expectedBits, nil | ||
} | ||
|
||
// ToScope returns a normalized scope string without any duplicates. | ||
func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope { | ||
var scopes []string | ||
|
||
// iterate over all scopes, and reconstruct the bitmap | ||
// if the reconstructed bitmap doesn't change, then the scope is already included | ||
var reconstruct AccessTokenScopeBitmap | ||
|
||
for _, singleScope := range allAccessTokenScopes { | ||
// no need for error checking here, since we know the scope is valid | ||
if ok, _ := bitmap.HasScope(singleScope); ok { | ||
current := reconstruct | allAccessTokenScopeBits[singleScope] | ||
if current == reconstruct { | ||
continue | ||
} | ||
|
||
reconstruct = current | ||
scopes = append(scopes, string(singleScope)) | ||
} | ||
} | ||
|
||
scope := AccessTokenScope(strings.Join(scopes, ",")) | ||
scope = AccessTokenScope(strings.ReplaceAll( | ||
string(scope), | ||
"repo,admin:org,admin:public_key,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", | ||
"all", | ||
)) | ||
return scope | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
// Copyright 2022 The Gitea Authors. All rights reserved. | ||
// SPDX-License-Identifier: MIT | ||
|
||
package auth | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestAccessTokenScope_Normalize(t *testing.T) { | ||
tests := []struct { | ||
in AccessTokenScope | ||
out AccessTokenScope | ||
err error | ||
}{ | ||
{"", "", nil}, | ||
{"repo", "repo", nil}, | ||
{"repo,repo:status", "repo", nil}, | ||
{"repo,public_repo", "repo", nil}, | ||
{"admin:public_key,write:public_key", "admin:public_key", nil}, | ||
{"admin:public_key,read:public_key", "admin:public_key", nil}, | ||
{"write:public_key,read:public_key", "write:public_key", nil}, // read is include in write | ||
{"admin:repo_hook,write:repo_hook", "admin:repo_hook", nil}, | ||
{"admin:repo_hook,read:repo_hook", "admin:repo_hook", nil}, | ||
{"repo,admin:repo_hook,read:repo_hook", "repo", nil}, // admin:repo_hook is a child scope of repo | ||
{"repo,read:repo_hook", "repo", nil}, // read:repo_hook is a child scope of repo | ||
{"user", "user", nil}, | ||
{"user,read:user", "user", nil}, | ||
{"user,admin:org,write:org", "admin:org,user", nil}, | ||
{"admin:org,write:org,user", "admin:org,user", nil}, | ||
{"package", "package", nil}, | ||
{"package,write:package", "package", nil}, | ||
{"package,write:package,delete:package", "package", nil}, | ||
{"write:package,read:package", "write:package", nil}, // read is include in write | ||
{"write:package,delete:package", "write:package,delete:package", nil}, // write and delete are not include in each other | ||
{"admin:gpg_key", "admin:gpg_key", nil}, | ||
{"admin:gpg_key,write:gpg_key", "admin:gpg_key", nil}, | ||
{"admin:gpg_key,write:gpg_key,user", "user,admin:gpg_key", nil}, | ||
{"admin:application,write:application,user", "user,admin:application", nil}, | ||
{"all", "all", nil}, | ||
{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", "all", nil}, | ||
{"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}, | ||
} | ||
|
||
for _, test := range tests { | ||
t.Run(string(test.in), func(t *testing.T) { | ||
scope, err := test.in.Normalize() | ||
assert.Equal(t, test.out, scope) | ||
assert.Equal(t, test.err, err) | ||
}) | ||
} | ||
} | ||
|
||
func TestAccessTokenScope_HasScope(t *testing.T) { | ||
tests := []struct { | ||
in AccessTokenScope | ||
scope AccessTokenScope | ||
out bool | ||
err error | ||
}{ | ||
{"repo", "repo", true, nil}, | ||
{"repo", "repo:status", true, nil}, | ||
{"repo", "public_repo", true, nil}, | ||
{"repo", "admin:org", false, nil}, | ||
{"repo", "admin:public_key", false, nil}, | ||
{"repo:status", "repo", false, nil}, | ||
{"repo:status", "public_repo", false, nil}, | ||
{"admin:org", "write:org", true, nil}, | ||
{"admin:org", "read:org", true, nil}, | ||
{"admin:org", "admin:org", true, nil}, | ||
{"user", "read:user", true, nil}, | ||
{"package", "write:package", true, nil}, | ||
} | ||
|
||
for _, test := range tests { | ||
t.Run(string(test.in), func(t *testing.T) { | ||
scope, err := test.in.HasScope(test.scope) | ||
assert.Equal(t, test.out, scope) | ||
assert.Equal(t, test.err, err) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.