mirror of https://github.com/go-gitea/gitea
Redesign Scoped Access Tokens (#24767)
## Changes - Adds the following high level access scopes, each with `read` and `write` levels: - `activitypub` - `admin` (hidden if user is not a site admin) - `misc` - `notification` - `organization` - `package` - `issue` - `repository` - `user` - Adds new middleware function `tokenRequiresScopes()` in addition to `reqToken()` - `tokenRequiresScopes()` is used for each high-level api section - _if_ a scoped token is present, checks that the required scope is included based on the section and HTTP method - `reqToken()` is used for individual routes - checks that required authentication is present (but does not check scope levels as this will already have been handled by `tokenRequiresScopes()` - Adds migration to convert old scoped access tokens to the new set of scopes - Updates the user interface for scope selection ### User interface example <img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM" src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3"> <img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM" src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c"> ## tokenRequiresScopes Design Decision - `tokenRequiresScopes()` was added to more reliably cover api routes. For an incoming request, this function uses the given scope category (say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say `DELETE`) and verifies that any scoped tokens in use include `delete:organization`. - `reqToken()` is used to enforce auth for individual routes that require it. If a scoped token is not present for a request, `tokenRequiresScopes()` will not return an error ## TODO - [x] Alphabetize scope categories - [x] Change 'public repos only' to a radio button (private vs public). Also expand this to organizations - [X] Disable token creation if no scopes selected. Alternatively, show warning - [x] `reqToken()` is missing from many `POST/DELETE` routes in the api. `tokenRequiresScopes()` only checks that a given token has the correct scope, `reqToken()` must be used to check that a token (or some other auth) is present. - _This should be addressed in this PR_ - [x] The migration should be reviewed very carefully in order to minimize access changes to existing user tokens. - _This should be addressed in this PR_ - [x] Link to api to swagger documentation, clarify what read/write/delete levels correspond to - [x] Review cases where more than one scope is needed as this directly deviates from the api definition. - _This should be addressed in this PR_ - For example: ```go m.Group("/users/{username}/orgs", func() { m.Get("", reqToken(), org.ListUserOrgs) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context_service.UserAssignmentAPI()) ``` ## Future improvements - [ ] Add required scopes to swagger documentation - [ ] Redesign `reqToken()` to be opt-out rather than opt-in - [ ] Subdivide scopes like `repository` - [ ] Once a token is created, if it has no scopes, we should display text instead of an empty bullet point - [ ] If the 'public repos only' option is selected, should read categories be selected by default Closes #24501 Closes #24799 Co-authored-by: Jonathan Tran <jon@allspice.io> Co-authored-by: Kyle D <kdumontnu@gmail.com> Co-authored-by: silverwind <me@silverwind.io>pull/25050/head
parent
520eb57d76
commit
18de83b2a3
@ -0,0 +1,14 @@ |
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_20 //nolint
|
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/migrations/base" |
||||||
|
) |
||||||
|
|
||||||
|
func TestMain(m *testing.M) { |
||||||
|
base.MainTest(m) |
||||||
|
} |
@ -0,0 +1,360 @@ |
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_20 //nolint
|
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/log" |
||||||
|
|
||||||
|
"xorm.io/xorm" |
||||||
|
) |
||||||
|
|
||||||
|
// unknownAccessTokenScope represents the scope for an access token that isn't
|
||||||
|
// known be an old token or a new token.
|
||||||
|
type unknownAccessTokenScope string |
||||||
|
|
||||||
|
// AccessTokenScope represents the scope for an access token.
|
||||||
|
type AccessTokenScope string |
||||||
|
|
||||||
|
// for all categories, write implies read
|
||||||
|
const ( |
||||||
|
AccessTokenScopeAll AccessTokenScope = "all" |
||||||
|
AccessTokenScopePublicOnly AccessTokenScope = "public-only" // limited to public orgs/repos
|
||||||
|
|
||||||
|
AccessTokenScopeReadActivityPub AccessTokenScope = "read:activitypub" |
||||||
|
AccessTokenScopeWriteActivityPub AccessTokenScope = "write:activitypub" |
||||||
|
|
||||||
|
AccessTokenScopeReadAdmin AccessTokenScope = "read:admin" |
||||||
|
AccessTokenScopeWriteAdmin AccessTokenScope = "write:admin" |
||||||
|
|
||||||
|
AccessTokenScopeReadMisc AccessTokenScope = "read:misc" |
||||||
|
AccessTokenScopeWriteMisc AccessTokenScope = "write:misc" |
||||||
|
|
||||||
|
AccessTokenScopeReadNotification AccessTokenScope = "read:notification" |
||||||
|
AccessTokenScopeWriteNotification AccessTokenScope = "write:notification" |
||||||
|
|
||||||
|
AccessTokenScopeReadOrganization AccessTokenScope = "read:organization" |
||||||
|
AccessTokenScopeWriteOrganization AccessTokenScope = "write:organization" |
||||||
|
|
||||||
|
AccessTokenScopeReadPackage AccessTokenScope = "read:package" |
||||||
|
AccessTokenScopeWritePackage AccessTokenScope = "write:package" |
||||||
|
|
||||||
|
AccessTokenScopeReadIssue AccessTokenScope = "read:issue" |
||||||
|
AccessTokenScopeWriteIssue AccessTokenScope = "write:issue" |
||||||
|
|
||||||
|
AccessTokenScopeReadRepository AccessTokenScope = "read:repository" |
||||||
|
AccessTokenScopeWriteRepository AccessTokenScope = "write:repository" |
||||||
|
|
||||||
|
AccessTokenScopeReadUser AccessTokenScope = "read:user" |
||||||
|
AccessTokenScopeWriteUser AccessTokenScope = "write:user" |
||||||
|
) |
||||||
|
|
||||||
|
// 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
|
||||||
|
accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits | |
||||||
|
accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits | |
||||||
|
accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits | |
||||||
|
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits |
||||||
|
|
||||||
|
accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota |
||||||
|
|
||||||
|
accessTokenScopeReadActivityPubBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteActivityPubBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadActivityPubBits |
||||||
|
|
||||||
|
accessTokenScopeReadAdminBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteAdminBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadAdminBits |
||||||
|
|
||||||
|
accessTokenScopeReadMiscBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteMiscBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadMiscBits |
||||||
|
|
||||||
|
accessTokenScopeReadNotificationBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteNotificationBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadNotificationBits |
||||||
|
|
||||||
|
accessTokenScopeReadOrganizationBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteOrganizationBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadOrganizationBits |
||||||
|
|
||||||
|
accessTokenScopeReadPackageBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWritePackageBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadPackageBits |
||||||
|
|
||||||
|
accessTokenScopeReadIssueBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteIssueBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadIssueBits |
||||||
|
|
||||||
|
accessTokenScopeReadRepositoryBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteRepositoryBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadRepositoryBits |
||||||
|
|
||||||
|
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota |
||||||
|
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits |
||||||
|
|
||||||
|
// 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 precede child scopes.
|
||||||
|
var allAccessTokenScopes = []AccessTokenScope{ |
||||||
|
AccessTokenScopePublicOnly, |
||||||
|
AccessTokenScopeWriteActivityPub, AccessTokenScopeReadActivityPub, |
||||||
|
AccessTokenScopeWriteAdmin, AccessTokenScopeReadAdmin, |
||||||
|
AccessTokenScopeWriteMisc, AccessTokenScopeReadMisc, |
||||||
|
AccessTokenScopeWriteNotification, AccessTokenScopeReadNotification, |
||||||
|
AccessTokenScopeWriteOrganization, AccessTokenScopeReadOrganization, |
||||||
|
AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, |
||||||
|
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue, |
||||||
|
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository, |
||||||
|
AccessTokenScopeWriteUser, AccessTokenScopeReadUser, |
||||||
|
} |
||||||
|
|
||||||
|
// allAccessTokenScopeBits contains all access token scopes.
|
||||||
|
var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{ |
||||||
|
AccessTokenScopeAll: accessTokenScopeAllBits, |
||||||
|
AccessTokenScopePublicOnly: accessTokenScopePublicOnlyBits, |
||||||
|
AccessTokenScopeReadActivityPub: accessTokenScopeReadActivityPubBits, |
||||||
|
AccessTokenScopeWriteActivityPub: accessTokenScopeWriteActivityPubBits, |
||||||
|
AccessTokenScopeReadAdmin: accessTokenScopeReadAdminBits, |
||||||
|
AccessTokenScopeWriteAdmin: accessTokenScopeWriteAdminBits, |
||||||
|
AccessTokenScopeReadMisc: accessTokenScopeReadMiscBits, |
||||||
|
AccessTokenScopeWriteMisc: accessTokenScopeWriteMiscBits, |
||||||
|
AccessTokenScopeReadNotification: accessTokenScopeReadNotificationBits, |
||||||
|
AccessTokenScopeWriteNotification: accessTokenScopeWriteNotificationBits, |
||||||
|
AccessTokenScopeReadOrganization: accessTokenScopeReadOrganizationBits, |
||||||
|
AccessTokenScopeWriteOrganization: accessTokenScopeWriteOrganizationBits, |
||||||
|
AccessTokenScopeReadPackage: accessTokenScopeReadPackageBits, |
||||||
|
AccessTokenScopeWritePackage: accessTokenScopeWritePackageBits, |
||||||
|
AccessTokenScopeReadIssue: accessTokenScopeReadIssueBits, |
||||||
|
AccessTokenScopeWriteIssue: accessTokenScopeWriteIssueBits, |
||||||
|
AccessTokenScopeReadRepository: accessTokenScopeReadRepositoryBits, |
||||||
|
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits, |
||||||
|
AccessTokenScopeReadUser: accessTokenScopeReadUserBits, |
||||||
|
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits, |
||||||
|
} |
||||||
|
|
||||||
|
// 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(unknownScopes *[]unknownAccessTokenScope) AccessTokenScope { |
||||||
|
var scopes []string |
||||||
|
|
||||||
|
// Preserve unknown scopes, and put them at the beginning so that it's clear
|
||||||
|
// when debugging.
|
||||||
|
if unknownScopes != nil { |
||||||
|
for _, unknownScope := range *unknownScopes { |
||||||
|
scopes = append(scopes, string(unknownScope)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 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), |
||||||
|
"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", |
||||||
|
"all", |
||||||
|
)) |
||||||
|
return scope |
||||||
|
} |
||||||
|
|
||||||
|
// parse the scope string into a bitmap, thus removing possible duplicates.
|
||||||
|
func (s AccessTokenScope) parse() (accessTokenScopeBitmap, *[]unknownAccessTokenScope) { |
||||||
|
var bitmap accessTokenScopeBitmap |
||||||
|
var unknownScopes []unknownAccessTokenScope |
||||||
|
|
||||||
|
// The following is the more performant equivalent of 'for _, v := range strings.Split(remainingScope, ",")' as this is hot code
|
||||||
|
remainingScopes := string(s) |
||||||
|
for len(remainingScopes) > 0 { |
||||||
|
i := strings.IndexByte(remainingScopes, ',') |
||||||
|
var v string |
||||||
|
if i < 0 { |
||||||
|
v = remainingScopes |
||||||
|
remainingScopes = "" |
||||||
|
} else if i+1 >= len(remainingScopes) { |
||||||
|
v = remainingScopes[:i] |
||||||
|
remainingScopes = "" |
||||||
|
} else { |
||||||
|
v = remainingScopes[:i] |
||||||
|
remainingScopes = remainingScopes[i+1:] |
||||||
|
} |
||||||
|
singleScope := AccessTokenScope(v) |
||||||
|
if singleScope == "" { |
||||||
|
continue |
||||||
|
} |
||||||
|
if singleScope == AccessTokenScopeAll { |
||||||
|
bitmap |= accessTokenScopeAllBits |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
bits, ok := allAccessTokenScopeBits[singleScope] |
||||||
|
if !ok { |
||||||
|
unknownScopes = append(unknownScopes, unknownAccessTokenScope(string(singleScope))) |
||||||
|
} |
||||||
|
bitmap |= bits |
||||||
|
} |
||||||
|
|
||||||
|
return bitmap, &unknownScopes |
||||||
|
} |
||||||
|
|
||||||
|
// NormalizePreservingUnknown returns a normalized scope string without any
|
||||||
|
// duplicates. Unknown scopes are included.
|
||||||
|
func (s AccessTokenScope) NormalizePreservingUnknown() AccessTokenScope { |
||||||
|
bitmap, unknownScopes := s.parse() |
||||||
|
|
||||||
|
return bitmap.toScope(unknownScopes) |
||||||
|
} |
||||||
|
|
||||||
|
// OldAccessTokenScope represents the scope for an access token.
|
||||||
|
type OldAccessTokenScope string |
||||||
|
|
||||||
|
const ( |
||||||
|
OldAccessTokenScopeAll OldAccessTokenScope = "all" |
||||||
|
|
||||||
|
OldAccessTokenScopeRepo OldAccessTokenScope = "repo" |
||||||
|
OldAccessTokenScopeRepoStatus OldAccessTokenScope = "repo:status" |
||||||
|
OldAccessTokenScopePublicRepo OldAccessTokenScope = "public_repo" |
||||||
|
|
||||||
|
OldAccessTokenScopeAdminOrg OldAccessTokenScope = "admin:org" |
||||||
|
OldAccessTokenScopeWriteOrg OldAccessTokenScope = "write:org" |
||||||
|
OldAccessTokenScopeReadOrg OldAccessTokenScope = "read:org" |
||||||
|
|
||||||
|
OldAccessTokenScopeAdminPublicKey OldAccessTokenScope = "admin:public_key" |
||||||
|
OldAccessTokenScopeWritePublicKey OldAccessTokenScope = "write:public_key" |
||||||
|
OldAccessTokenScopeReadPublicKey OldAccessTokenScope = "read:public_key" |
||||||
|
|
||||||
|
OldAccessTokenScopeAdminRepoHook OldAccessTokenScope = "admin:repo_hook" |
||||||
|
OldAccessTokenScopeWriteRepoHook OldAccessTokenScope = "write:repo_hook" |
||||||
|
OldAccessTokenScopeReadRepoHook OldAccessTokenScope = "read:repo_hook" |
||||||
|
|
||||||
|
OldAccessTokenScopeAdminOrgHook OldAccessTokenScope = "admin:org_hook" |
||||||
|
|
||||||
|
OldAccessTokenScopeNotification OldAccessTokenScope = "notification" |
||||||
|
|
||||||
|
OldAccessTokenScopeUser OldAccessTokenScope = "user" |
||||||
|
OldAccessTokenScopeReadUser OldAccessTokenScope = "read:user" |
||||||
|
OldAccessTokenScopeUserEmail OldAccessTokenScope = "user:email" |
||||||
|
OldAccessTokenScopeUserFollow OldAccessTokenScope = "user:follow" |
||||||
|
|
||||||
|
OldAccessTokenScopeDeleteRepo OldAccessTokenScope = "delete_repo" |
||||||
|
|
||||||
|
OldAccessTokenScopePackage OldAccessTokenScope = "package" |
||||||
|
OldAccessTokenScopeWritePackage OldAccessTokenScope = "write:package" |
||||||
|
OldAccessTokenScopeReadPackage OldAccessTokenScope = "read:package" |
||||||
|
OldAccessTokenScopeDeletePackage OldAccessTokenScope = "delete:package" |
||||||
|
|
||||||
|
OldAccessTokenScopeAdminGPGKey OldAccessTokenScope = "admin:gpg_key" |
||||||
|
OldAccessTokenScopeWriteGPGKey OldAccessTokenScope = "write:gpg_key" |
||||||
|
OldAccessTokenScopeReadGPGKey OldAccessTokenScope = "read:gpg_key" |
||||||
|
|
||||||
|
OldAccessTokenScopeAdminApplication OldAccessTokenScope = "admin:application" |
||||||
|
OldAccessTokenScopeWriteApplication OldAccessTokenScope = "write:application" |
||||||
|
OldAccessTokenScopeReadApplication OldAccessTokenScope = "read:application" |
||||||
|
|
||||||
|
OldAccessTokenScopeSudo OldAccessTokenScope = "sudo" |
||||||
|
) |
||||||
|
|
||||||
|
var accessTokenScopeMap = map[OldAccessTokenScope][]AccessTokenScope{ |
||||||
|
OldAccessTokenScopeAll: {AccessTokenScopeAll}, |
||||||
|
OldAccessTokenScopeRepo: {AccessTokenScopeWriteRepository}, |
||||||
|
OldAccessTokenScopeRepoStatus: {AccessTokenScopeWriteRepository}, |
||||||
|
OldAccessTokenScopePublicRepo: {AccessTokenScopePublicOnly, AccessTokenScopeWriteRepository}, |
||||||
|
OldAccessTokenScopeAdminOrg: {AccessTokenScopeWriteOrganization}, |
||||||
|
OldAccessTokenScopeWriteOrg: {AccessTokenScopeWriteOrganization}, |
||||||
|
OldAccessTokenScopeReadOrg: {AccessTokenScopeReadOrganization}, |
||||||
|
OldAccessTokenScopeAdminPublicKey: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeWritePublicKey: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeReadPublicKey: {AccessTokenScopeReadUser}, |
||||||
|
OldAccessTokenScopeAdminRepoHook: {AccessTokenScopeWriteRepository}, |
||||||
|
OldAccessTokenScopeWriteRepoHook: {AccessTokenScopeWriteRepository}, |
||||||
|
OldAccessTokenScopeReadRepoHook: {AccessTokenScopeReadRepository}, |
||||||
|
OldAccessTokenScopeAdminOrgHook: {AccessTokenScopeWriteOrganization}, |
||||||
|
OldAccessTokenScopeNotification: {AccessTokenScopeWriteNotification}, |
||||||
|
OldAccessTokenScopeUser: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeReadUser: {AccessTokenScopeReadUser}, |
||||||
|
OldAccessTokenScopeUserEmail: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeUserFollow: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeDeleteRepo: {AccessTokenScopeWriteRepository}, |
||||||
|
OldAccessTokenScopePackage: {AccessTokenScopeWritePackage}, |
||||||
|
OldAccessTokenScopeWritePackage: {AccessTokenScopeWritePackage}, |
||||||
|
OldAccessTokenScopeReadPackage: {AccessTokenScopeReadPackage}, |
||||||
|
OldAccessTokenScopeDeletePackage: {AccessTokenScopeWritePackage}, |
||||||
|
OldAccessTokenScopeAdminGPGKey: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeWriteGPGKey: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeReadGPGKey: {AccessTokenScopeReadUser}, |
||||||
|
OldAccessTokenScopeAdminApplication: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeWriteApplication: {AccessTokenScopeWriteUser}, |
||||||
|
OldAccessTokenScopeReadApplication: {AccessTokenScopeReadUser}, |
||||||
|
OldAccessTokenScopeSudo: {AccessTokenScopeWriteAdmin}, |
||||||
|
} |
||||||
|
|
||||||
|
type AccessToken struct { |
||||||
|
ID int64 `xorm:"pk autoincr"` |
||||||
|
Scope string |
||||||
|
} |
||||||
|
|
||||||
|
func ConvertScopedAccessTokens(x *xorm.Engine) error { |
||||||
|
var tokens []*AccessToken |
||||||
|
|
||||||
|
if err := x.Find(&tokens); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
for _, token := range tokens { |
||||||
|
var scopes []string |
||||||
|
allNewScopesMap := make(map[AccessTokenScope]bool) |
||||||
|
for _, oldScope := range strings.Split(token.Scope, ",") { |
||||||
|
if newScopes, exists := accessTokenScopeMap[OldAccessTokenScope(oldScope)]; exists { |
||||||
|
for _, newScope := range newScopes { |
||||||
|
allNewScopesMap[newScope] = true |
||||||
|
} |
||||||
|
} else { |
||||||
|
log.Debug("access token scope not recognized as old token scope %s; preserving it", oldScope) |
||||||
|
scopes = append(scopes, oldScope) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for s := range allNewScopesMap { |
||||||
|
scopes = append(scopes, string(s)) |
||||||
|
} |
||||||
|
scope := AccessTokenScope(strings.Join(scopes, ",")) |
||||||
|
|
||||||
|
// normalize the scope
|
||||||
|
normScope := scope.NormalizePreservingUnknown() |
||||||
|
|
||||||
|
token.Scope = string(normScope) |
||||||
|
|
||||||
|
// update the db entry with the new scope
|
||||||
|
if _, err := x.Cols("scope").ID(token.ID).Update(token); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,110 @@ |
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_20 //nolint
|
||||||
|
|
||||||
|
import ( |
||||||
|
"sort" |
||||||
|
"strings" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/migrations/base" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
type testCase struct { |
||||||
|
Old OldAccessTokenScope |
||||||
|
New AccessTokenScope |
||||||
|
} |
||||||
|
|
||||||
|
func createOldTokenScope(scopes ...OldAccessTokenScope) OldAccessTokenScope { |
||||||
|
s := make([]string, 0, len(scopes)) |
||||||
|
for _, os := range scopes { |
||||||
|
s = append(s, string(os)) |
||||||
|
} |
||||||
|
return OldAccessTokenScope(strings.Join(s, ",")) |
||||||
|
} |
||||||
|
|
||||||
|
func createNewTokenScope(scopes ...AccessTokenScope) AccessTokenScope { |
||||||
|
s := make([]string, 0, len(scopes)) |
||||||
|
for _, os := range scopes { |
||||||
|
s = append(s, string(os)) |
||||||
|
} |
||||||
|
return AccessTokenScope(strings.Join(s, ",")) |
||||||
|
} |
||||||
|
|
||||||
|
func Test_ConvertScopedAccessTokens(t *testing.T) { |
||||||
|
tests := []testCase{ |
||||||
|
{ |
||||||
|
createOldTokenScope(OldAccessTokenScopeRepo, OldAccessTokenScopeUserFollow), |
||||||
|
createNewTokenScope(AccessTokenScopeWriteRepository, AccessTokenScopeWriteUser), |
||||||
|
}, |
||||||
|
{ |
||||||
|
createOldTokenScope(OldAccessTokenScopeUser, OldAccessTokenScopeWritePackage, OldAccessTokenScopeSudo), |
||||||
|
createNewTokenScope(AccessTokenScopeWriteAdmin, AccessTokenScopeWritePackage, AccessTokenScopeWriteUser), |
||||||
|
}, |
||||||
|
{ |
||||||
|
createOldTokenScope(), |
||||||
|
createNewTokenScope(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
createOldTokenScope(OldAccessTokenScopeReadGPGKey, OldAccessTokenScopeReadOrg, OldAccessTokenScopeAll), |
||||||
|
createNewTokenScope(AccessTokenScopeAll), |
||||||
|
}, |
||||||
|
{ |
||||||
|
createOldTokenScope(OldAccessTokenScopeReadGPGKey, "invalid"), |
||||||
|
createNewTokenScope("invalid", AccessTokenScopeReadUser), |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
// add a test for each individual mapping
|
||||||
|
for oldScope, newScope := range accessTokenScopeMap { |
||||||
|
tests = append(tests, testCase{ |
||||||
|
oldScope, |
||||||
|
createNewTokenScope(newScope...), |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
x, deferable := base.PrepareTestEnv(t, 0, new(AccessToken)) |
||||||
|
defer deferable() |
||||||
|
if x == nil || t.Failed() { |
||||||
|
t.Skip() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// verify that no fixtures were loaded
|
||||||
|
count, err := x.Count(&AccessToken{}) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Equal(t, int64(0), count) |
||||||
|
|
||||||
|
for _, tc := range tests { |
||||||
|
_, err = x.Insert(&AccessToken{ |
||||||
|
Scope: string(tc.Old), |
||||||
|
}) |
||||||
|
assert.NoError(t, err) |
||||||
|
} |
||||||
|
|
||||||
|
// migrate the scopes
|
||||||
|
err = ConvertScopedAccessTokens(x) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
// migrate the scopes again (migration should be idempotent)
|
||||||
|
err = ConvertScopedAccessTokens(x) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
tokens := make([]AccessToken, 0) |
||||||
|
err = x.Find(&tokens) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Equal(t, len(tests), len(tokens)) |
||||||
|
|
||||||
|
// sort the tokens (insertion order by auto-incrementing primary key)
|
||||||
|
sort.Slice(tokens, func(i, j int) bool { |
||||||
|
return tokens[i].ID < tokens[j].ID |
||||||
|
}) |
||||||
|
|
||||||
|
// verify that the converted scopes are equal to the expected test result
|
||||||
|
for idx, newToken := range tokens { |
||||||
|
assert.Equal(t, string(tests[idx].New), newToken.Scope) |
||||||
|
} |
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue