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="654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="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>
This commit is contained in:
Jack Hay 2023-06-04 14:57:16 -04:00 committed by GitHub
parent 520eb57d76
commit 18de83b2a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 2183 additions and 1038 deletions

View file

@ -189,7 +189,7 @@ func TestAPISearchRepo(t *testing.T) {
if userToLogin != nil && userToLogin.ID > 0 {
testName = fmt.Sprintf("LoggedUser%d", userToLogin.ID)
session := loginUser(t, userToLogin.Name)
token = getTokenForLoggedInUser(t, session)
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
userID = userToLogin.ID
} else {
testName = "AnonymousUser"
@ -295,7 +295,7 @@ func TestAPIOrgRepos(t *testing.T) {
for userToLogin, expected := range expectedResults {
testName := fmt.Sprintf("LoggedUser%d", userToLogin.ID)
session := loginUser(t, userToLogin.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrg)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
t.Run(testName, func(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos?token="+token, sourceOrg.Name)
@ -317,7 +317,7 @@ func TestAPIGetRepoByIDUnauthorized(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
req := NewRequestf(t, "GET", "/api/v1/repositories/2?token="+token)
MakeRequest(t, req, http.StatusNotFound)
}
@ -341,7 +341,7 @@ func TestAPIRepoMigrate(t *testing.T) {
for _, testCase := range testCases {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate?token="+token, &api.MigrateRepoOptions{
CloneAddr: testCase.cloneURL,
RepoOwnerID: testCase.userID,
@ -371,7 +371,7 @@ func TestAPIRepoMigrateConflict(t *testing.T) {
func testAPIRepoMigrateConflict(t *testing.T, u *url.URL) {
username := "user2"
baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeRepo)
baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
u.Path = baseAPITestContext.GitPath()
@ -406,7 +406,7 @@ func TestAPIMirrorSyncNonMirrorRepo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
var repo api.Repository
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1")
@ -438,7 +438,7 @@ func TestAPIOrgRepoCreate(t *testing.T) {
for _, testCase := range testCases {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAdminOrg)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos?token="+token, testCase.orgName), &api.CreateRepoOption{
Name: testCase.repoName,
})
@ -452,7 +452,7 @@ func TestAPIRepoCreateConflict(t *testing.T) {
func testAPIRepoCreateConflict(t *testing.T, u *url.URL) {
username := "user2"
baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeRepo)
baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
u.Path = baseAPITestContext.GitPath()
@ -502,7 +502,7 @@ func TestAPIRepoTransfer(t *testing.T) {
// create repo to move
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
repoName := "moveME"
apiRepo := new(api.Repository)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/repos?token=%s", token), &api.CreateRepoOption{
@ -520,7 +520,7 @@ func TestAPIRepoTransfer(t *testing.T) {
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
session = loginUser(t, user.Name)
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer?token=%s", repo.OwnerName, repo.Name, token), &api.TransferRepoOption{
NewOwner: testCase.newOwner,
TeamIDs: testCase.teams,
@ -537,7 +537,7 @@ func transfer(t *testing.T) *repo_model.Repository {
// create repo to move
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
repoName := "moveME"
apiRepo := new(api.Repository)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/repos?token=%s", token), &api.CreateRepoOption{
@ -567,7 +567,7 @@ func TestAPIAcceptTransfer(t *testing.T) {
// try to accept with not authorized user
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token))
MakeRequest(t, req, http.StatusForbidden)
@ -577,7 +577,7 @@ func TestAPIAcceptTransfer(t *testing.T) {
// accept transfer
session = loginUser(t, "user4")
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", repo.OwnerName, repo.Name, token))
resp := MakeRequest(t, req, http.StatusAccepted)
@ -593,7 +593,7 @@ func TestAPIRejectTransfer(t *testing.T) {
// try to reject with not authorized user
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token))
MakeRequest(t, req, http.StatusForbidden)
@ -603,7 +603,7 @@ func TestAPIRejectTransfer(t *testing.T) {
// reject transfer
session = loginUser(t, "user4")
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token))
resp := MakeRequest(t, req, http.StatusOK)
@ -617,7 +617,7 @@ func TestAPIGenerateRepo(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
templateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 44})
@ -653,7 +653,7 @@ func TestAPIRepoGetReviewers(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/reviewers?token=%s", user.Name, repo.Name, token)
@ -667,7 +667,7 @@ func TestAPIRepoGetAssignees(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/assignees?token=%s", user.Name, repo.Name, token)