diff options
author | Jack Hay <jack@allspice.io> | 2023-06-04 14:57:16 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-06-04 20:57:16 +0200 |
commit | 18de83b2a3fc120922096b7348d6375094ae1532 (patch) | |
tree | a9724bcb6f00a040e5a16970ce56931cd1aa3d51 /models | |
parent | 520eb57d7642a5fca3df319e5b5d1c7c9018087c (diff) | |
download | gitea-18de83b2a3fc120922096b7348d6375094ae1532.tar.gz gitea-18de83b2a3fc120922096b7348d6375094ae1532.zip |
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>
Diffstat (limited to 'models')
-rw-r--r-- | models/auth/token.go | 9 | ||||
-rw-r--r-- | models/auth/token_scope.go | 348 | ||||
-rw-r--r-- | models/auth/token_scope_test.go | 106 | ||||
-rw-r--r-- | models/migrations/migrations.go | 2 | ||||
-rw-r--r-- | models/migrations/v1_20/main_test.go | 14 | ||||
-rw-r--r-- | models/migrations/v1_20/v259.go | 360 | ||||
-rw-r--r-- | models/migrations/v1_20/v259_test.go | 110 |
7 files changed, 762 insertions, 187 deletions
diff --git a/models/auth/token.go b/models/auth/token.go index 3f9f117f73..9374fe38c2 100644 --- a/models/auth/token.go +++ b/models/auth/token.go @@ -112,6 +112,15 @@ func NewAccessToken(t *AccessToken) error { return err } +// DisplayPublicOnly whether to display this as a public-only token. +func (t *AccessToken) DisplayPublicOnly() bool { + publicOnly, err := t.Scope.PublicOnly() + if err != nil { + return false + } + return publicOnly +} + func getAccessTokenIDFromCache(token string) int64 { if successfulAccessTokenCache == nil { return 0 diff --git a/models/auth/token_scope.go b/models/auth/token_scope.go index 06c89fecc2..61e684ea27 100644 --- a/models/auth/token_scope.go +++ b/models/auth/token_scope.go @@ -6,113 +6,122 @@ package auth import ( "fmt" "strings" + + "code.gitea.io/gitea/models/perm" ) -// AccessTokenScope represents the scope for an access token. -type AccessTokenScope string +// AccessTokenScopeCategory represents the scope category for an access token +type AccessTokenScopeCategory int const ( - AccessTokenScopeAll AccessTokenScope = "all" + AccessTokenScopeCategoryActivityPub = iota + AccessTokenScopeCategoryAdmin + AccessTokenScopeCategoryMisc + AccessTokenScopeCategoryNotification + AccessTokenScopeCategoryOrganization + AccessTokenScopeCategoryPackage + AccessTokenScopeCategoryIssue + AccessTokenScopeCategoryRepository + AccessTokenScopeCategoryUser +) - AccessTokenScopeRepo AccessTokenScope = "repo" - AccessTokenScopeRepoStatus AccessTokenScope = "repo:status" - AccessTokenScopePublicRepo AccessTokenScope = "public_repo" +// AllAccessTokenScopeCategories contains all access token scope categories +var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{ + AccessTokenScopeCategoryActivityPub, + AccessTokenScopeCategoryAdmin, + AccessTokenScopeCategoryMisc, + AccessTokenScopeCategoryNotification, + AccessTokenScopeCategoryOrganization, + AccessTokenScopeCategoryPackage, + AccessTokenScopeCategoryIssue, + AccessTokenScopeCategoryRepository, + AccessTokenScopeCategoryUser, +} - AccessTokenScopeAdminOrg AccessTokenScope = "admin:org" - AccessTokenScopeWriteOrg AccessTokenScope = "write:org" - AccessTokenScopeReadOrg AccessTokenScope = "read:org" +// AccessTokenScopeLevel represents the access levels without a given scope category +type AccessTokenScopeLevel int - AccessTokenScopeAdminPublicKey AccessTokenScope = "admin:public_key" - AccessTokenScopeWritePublicKey AccessTokenScope = "write:public_key" - AccessTokenScopeReadPublicKey AccessTokenScope = "read:public_key" +const ( + NoAccess AccessTokenScopeLevel = iota + Read + Write +) + +// AccessTokenScope represents the scope for an access token. +type AccessTokenScope string - AccessTokenScopeAdminRepoHook AccessTokenScope = "admin:repo_hook" - AccessTokenScopeWriteRepoHook AccessTokenScope = "write:repo_hook" - AccessTokenScopeReadRepoHook AccessTokenScope = "read:repo_hook" +// for all categories, write implies read +const ( + AccessTokenScopeAll AccessTokenScope = "all" + AccessTokenScopePublicOnly AccessTokenScope = "public-only" // limited to public orgs/repos - AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook" + AccessTokenScopeReadActivityPub AccessTokenScope = "read:activitypub" + AccessTokenScopeWriteActivityPub AccessTokenScope = "write:activitypub" - AccessTokenScopeAdminUserHook AccessTokenScope = "admin:user_hook" + AccessTokenScopeReadAdmin AccessTokenScope = "read:admin" + AccessTokenScopeWriteAdmin AccessTokenScope = "write:admin" - AccessTokenScopeNotification AccessTokenScope = "notification" + AccessTokenScopeReadMisc AccessTokenScope = "read:misc" + AccessTokenScopeWriteMisc AccessTokenScope = "write:misc" - AccessTokenScopeUser AccessTokenScope = "user" - AccessTokenScopeReadUser AccessTokenScope = "read:user" - AccessTokenScopeUserEmail AccessTokenScope = "user:email" - AccessTokenScopeUserFollow AccessTokenScope = "user:follow" + AccessTokenScopeReadNotification AccessTokenScope = "read:notification" + AccessTokenScopeWriteNotification AccessTokenScope = "write:notification" - AccessTokenScopeDeleteRepo AccessTokenScope = "delete_repo" + AccessTokenScopeReadOrganization AccessTokenScope = "read:organization" + AccessTokenScopeWriteOrganization AccessTokenScope = "write:organization" - AccessTokenScopePackage AccessTokenScope = "package" - AccessTokenScopeWritePackage AccessTokenScope = "write:package" - AccessTokenScopeReadPackage AccessTokenScope = "read:package" - AccessTokenScopeDeletePackage AccessTokenScope = "delete:package" + AccessTokenScopeReadPackage AccessTokenScope = "read:package" + AccessTokenScopeWritePackage AccessTokenScope = "write:package" - AccessTokenScopeAdminGPGKey AccessTokenScope = "admin:gpg_key" - AccessTokenScopeWriteGPGKey AccessTokenScope = "write:gpg_key" - AccessTokenScopeReadGPGKey AccessTokenScope = "read:gpg_key" + AccessTokenScopeReadIssue AccessTokenScope = "read:issue" + AccessTokenScopeWriteIssue AccessTokenScope = "write:issue" - AccessTokenScopeAdminApplication AccessTokenScope = "admin:application" - AccessTokenScopeWriteApplication AccessTokenScope = "write:application" - AccessTokenScopeReadApplication AccessTokenScope = "read:application" + AccessTokenScopeReadRepository AccessTokenScope = "read:repository" + AccessTokenScopeWriteRepository AccessTokenScope = "write:repository" - AccessTokenScopeSudo AccessTokenScope = "sudo" + AccessTokenScopeReadUser AccessTokenScope = "read:user" + AccessTokenScopeWriteUser AccessTokenScope = "write:user" ) -// AccessTokenScopeBitmap represents a bitmap of access token scopes. -type AccessTokenScopeBitmap uint64 +// 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 | AccessTokenScopeAdminUserHookBits | - 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 + // AccessTokenScopeAllBits is the bitmap of all access token scopes + accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits | + accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits | + accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits | + accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits - AccessTokenScopeAdminRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteRepoHookBits - AccessTokenScopeWriteRepoHookBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadRepoHookBits - AccessTokenScopeReadRepoHookBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota - AccessTokenScopeAdminOrgHookBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadActivityPubBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteActivityPubBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadActivityPubBits - AccessTokenScopeAdminUserHookBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadAdminBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteAdminBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadAdminBits - AccessTokenScopeNotificationBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadMiscBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteMiscBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadMiscBits - AccessTokenScopeUserBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadUserBits | AccessTokenScopeUserEmailBits | AccessTokenScopeUserFollowBits - AccessTokenScopeReadUserBits AccessTokenScopeBitmap = 1 << iota - AccessTokenScopeUserEmailBits AccessTokenScopeBitmap = 1 << iota - AccessTokenScopeUserFollowBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadNotificationBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteNotificationBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadNotificationBits - AccessTokenScopeDeleteRepoBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadOrganizationBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteOrganizationBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadOrganizationBits - AccessTokenScopePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWritePackageBits | AccessTokenScopeDeletePackageBits - AccessTokenScopeWritePackageBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadPackageBits - AccessTokenScopeReadPackageBits AccessTokenScopeBitmap = 1 << iota - AccessTokenScopeDeletePackageBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadPackageBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWritePackageBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadPackageBits - AccessTokenScopeAdminGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteGPGKeyBits - AccessTokenScopeWriteGPGKeyBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadGPGKeyBits - AccessTokenScopeReadGPGKeyBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadIssueBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteIssueBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadIssueBits - AccessTokenScopeAdminApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeWriteApplicationBits - AccessTokenScopeWriteApplicationBits AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadApplicationBits - AccessTokenScopeReadApplicationBits AccessTokenScopeBitmap = 1 << iota + accessTokenScopeReadRepositoryBits accessTokenScopeBitmap = 1 << iota + accessTokenScopeWriteRepositoryBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadRepositoryBits - AccessTokenScopeSudoBits AccessTokenScopeBitmap = 1 << iota + 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, @@ -120,61 +129,110 @@ const ( ) // allAccessTokenScopes contains all access token scopes. -// The order is important: parent scope must precedes child scopes. +// The order is important: parent scope must precede child scopes. var allAccessTokenScopes = []AccessTokenScope{ - AccessTokenScopeRepo, AccessTokenScopeRepoStatus, AccessTokenScopePublicRepo, - AccessTokenScopeAdminOrg, AccessTokenScopeWriteOrg, AccessTokenScopeReadOrg, - AccessTokenScopeAdminPublicKey, AccessTokenScopeWritePublicKey, AccessTokenScopeReadPublicKey, - AccessTokenScopeAdminRepoHook, AccessTokenScopeWriteRepoHook, AccessTokenScopeReadRepoHook, - AccessTokenScopeAdminOrgHook, - AccessTokenScopeAdminUserHook, - AccessTokenScopeNotification, - AccessTokenScopeUser, AccessTokenScopeReadUser, AccessTokenScopeUserEmail, AccessTokenScopeUserFollow, - AccessTokenScopeDeleteRepo, - AccessTokenScopePackage, AccessTokenScopeWritePackage, AccessTokenScopeReadPackage, AccessTokenScopeDeletePackage, - AccessTokenScopeAdminGPGKey, AccessTokenScopeWriteGPGKey, AccessTokenScopeReadGPGKey, - AccessTokenScopeAdminApplication, AccessTokenScopeWriteApplication, AccessTokenScopeReadApplication, - AccessTokenScopeSudo, + 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{ - 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, - AccessTokenScopeAdminUserHook: AccessTokenScopeAdminUserHookBits, - 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, +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, +} + +// readAccessTokenScopes maps a scope category to the read permission scope +var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]AccessTokenScope{ + Read: { + AccessTokenScopeCategoryActivityPub: AccessTokenScopeReadActivityPub, + AccessTokenScopeCategoryAdmin: AccessTokenScopeReadAdmin, + AccessTokenScopeCategoryMisc: AccessTokenScopeReadMisc, + AccessTokenScopeCategoryNotification: AccessTokenScopeReadNotification, + AccessTokenScopeCategoryOrganization: AccessTokenScopeReadOrganization, + AccessTokenScopeCategoryPackage: AccessTokenScopeReadPackage, + AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue, + AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository, + AccessTokenScopeCategoryUser: AccessTokenScopeReadUser, + }, + Write: { + AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub, + AccessTokenScopeCategoryAdmin: AccessTokenScopeWriteAdmin, + AccessTokenScopeCategoryMisc: AccessTokenScopeWriteMisc, + AccessTokenScopeCategoryNotification: AccessTokenScopeWriteNotification, + AccessTokenScopeCategoryOrganization: AccessTokenScopeWriteOrganization, + AccessTokenScopeCategoryPackage: AccessTokenScopeWritePackage, + AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue, + AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository, + AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser, + }, +} + +// GetRequiredScopes gets the specific scopes for a given level and categories +func GetRequiredScopes(level AccessTokenScopeLevel, scopeCategories ...AccessTokenScopeCategory) []AccessTokenScope { + scopes := make([]AccessTokenScope, 0, len(scopeCategories)) + for _, cat := range scopeCategories { + scopes = append(scopes, accessTokenScopes[level][cat]) + } + return scopes } -// Parse parses the scope string into a bitmap, thus removing possible duplicates. -func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) { - var bitmap AccessTokenScopeBitmap +// ContainsCategory checks if a list of categories contains a specific category +func ContainsCategory(categories []AccessTokenScopeCategory, category AccessTokenScopeCategory) bool { + for _, c := range categories { + if c == category { + return true + } + } + return false +} + +// GetScopeLevelFromAccessMode converts permission access mode to scope level +func GetScopeLevelFromAccessMode(mode perm.AccessMode) AccessTokenScopeLevel { + switch mode { + case perm.AccessModeNone: + return NoAccess + case perm.AccessModeRead: + return Read + case perm.AccessModeWrite: + return Write + case perm.AccessModeAdmin: + return Write + case perm.AccessModeOwner: + return Write + default: + return NoAccess + } +} + +// parse the scope string into a bitmap, thus removing possible duplicates. +func (s AccessTokenScope) parse() (accessTokenScopeBitmap, error) { + var bitmap accessTokenScopeBitmap // The following is the more performant equivalent of 'for _, v := range strings.Split(remainingScope, ",")' as this is hot code remainingScopes := string(s) @@ -196,7 +254,7 @@ func (s AccessTokenScope) Parse() (AccessTokenScopeBitmap, error) { continue } if singleScope == AccessTokenScopeAll { - bitmap |= AccessTokenScopeAllBits + bitmap |= accessTokenScopeAllBits continue } @@ -217,26 +275,42 @@ func (s AccessTokenScope) StringSlice() []string { // Normalize returns a normalized scope string without any duplicates. func (s AccessTokenScope) Normalize() (AccessTokenScope, error) { - bitmap, err := s.Parse() + bitmap, err := s.parse() if err != nil { return "", err } - return bitmap.ToScope(), nil + 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() +// PublicOnly checks if this token scope is limited to public resources +func (s AccessTokenScope) PublicOnly() (bool, error) { + bitmap, err := s.parse() if err != nil { return false, err } - return bitmap.HasScope(scope) + return bitmap.hasScope(AccessTokenScopePublicOnly) } // HasScope returns true if the string has the given scope -func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, error) { +func (s AccessTokenScope) HasScope(scopes ...AccessTokenScope) (bool, error) { + bitmap, err := s.parse() + if err != nil { + return false, err + } + + for _, s := range scopes { + if has, err := bitmap.hasScope(s); !has || err != nil { + return has, err + } + } + + return true, nil +} + +// 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) @@ -245,17 +319,17 @@ func (bitmap AccessTokenScopeBitmap) HasScope(scope AccessTokenScope) (bool, err return bitmap&expectedBits == expectedBits, nil } -// ToScope returns a normalized scope string without any duplicates. -func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope { +// 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 + 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 { + if ok, _ := bitmap.hasScope(singleScope); ok { current := reconstruct | allAccessTokenScopeBits[singleScope] if current == reconstruct { continue @@ -269,7 +343,7 @@ func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope { scope := AccessTokenScope(strings.Join(scopes, ",")) scope = AccessTokenScope(strings.ReplaceAll( string(scope), - "repo,admin:org,admin:public_key,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", + "write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", )) return scope diff --git a/models/auth/token_scope_test.go b/models/auth/token_scope_test.go index b96a5fd469..a6097e45d7 100644 --- a/models/auth/token_scope_test.go +++ b/models/auth/token_scope_test.go @@ -4,44 +4,35 @@ package auth import ( + "fmt" "testing" "github.com/stretchr/testify/assert" ) +type scopeTestNormalize struct { + in AccessTokenScope + out AccessTokenScope + err error +} + func TestAccessTokenScope_Normalize(t *testing.T) { - tests := []struct { - in AccessTokenScope - out AccessTokenScope - err error - }{ + tests := []scopeTestNormalize{ {"", "", 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}, + {"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil}, {"all", "all", nil}, - {"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,admin:user_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,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application,sudo", "all,sudo", 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}, + } + + for _, scope := range []string{"activitypub", "admin", "misc", "notification", "organization", "package", "issue", "repository", "user"} { + tests = append(tests, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%s", scope)), AccessTokenScope(fmt.Sprintf("read:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("write:%s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("write:%[1]s,read:%[1]s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%[1]s,write:%[1]s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%[1]s,write:%[1]s,write:%[1]s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, + ) } for _, test := range tests { @@ -53,31 +44,46 @@ func TestAccessTokenScope_Normalize(t *testing.T) { } } +type scopeTestHasScope struct { + in AccessTokenScope + scope AccessTokenScope + out bool + err error +} + 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}, + tests := []scopeTestHasScope{ + {"read:admin", "write:package", false, nil}, + {"all", "write:package", true, nil}, + {"write:package", "all", false, nil}, + {"public-only", "read:issue", false, nil}, + } + + for _, scope := range []string{"activitypub", "admin", "misc", "notification", "organization", "package", "issue", "repository", "user"} { + tests = append(tests, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("read:%s", scope)), + AccessTokenScope(fmt.Sprintf("read:%s", scope)), true, nil, + }, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("write:%s", scope)), + AccessTokenScope(fmt.Sprintf("write:%s", scope)), true, nil, + }, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("write:%s", scope)), + AccessTokenScope(fmt.Sprintf("read:%s", scope)), true, nil, + }, + scopeTestHasScope{ + AccessTokenScope(fmt.Sprintf("read:%s", scope)), + AccessTokenScope(fmt.Sprintf("write:%s", scope)), false, 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) + hasScope, err := test.in.HasScope(test.scope) + assert.Equal(t, test.out, hasScope) assert.Equal(t, test.err, err) }) } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 231c93cc74..d96c17bfb5 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -495,6 +495,8 @@ var migrations = []Migration{ NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable), // v258 -> 259 NewMigration("Add PinOrder Column", v1_20.AddPinOrderToIssue), + // v259 -> 260 + NewMigration("Convert scoped access tokens", v1_20.ConvertScopedAccessTokens), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_20/main_test.go b/models/migrations/v1_20/main_test.go new file mode 100644 index 0000000000..92a1a9f622 --- /dev/null +++ b/models/migrations/v1_20/main_test.go @@ -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) +} diff --git a/models/migrations/v1_20/v259.go b/models/migrations/v1_20/v259.go new file mode 100644 index 0000000000..5b8ced4ad7 --- /dev/null +++ b/models/migrations/v1_20/v259.go @@ -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 +} diff --git a/models/migrations/v1_20/v259_test.go b/models/migrations/v1_20/v259_test.go new file mode 100644 index 0000000000..5bc9a71391 --- /dev/null +++ b/models/migrations/v1_20/v259_test.go @@ -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) + } +} |