diff --git a/models/auth/TestGetRepositoriesAccessibleWithTokens/access_token_resource_repo.yml b/models/auth/TestGetRepositoriesAccessibleWithTokens/access_token_resource_repo.yml new file mode 100644 index 0000000000..1f1a552361 --- /dev/null +++ b/models/auth/TestGetRepositoriesAccessibleWithTokens/access_token_resource_repo.yml @@ -0,0 +1,17 @@ +- token_id: 3 + repo_id: 1 + created_unix: 1772158384 +- token_id: 3 + repo_id: 2 + created_unix: 1772158384 +- token_id: 3 + repo_id: 3 + created_unix: 1772158384 + +- token_id: 2 + repo_id: 1 + created_unix: 1772158384 + # (no repo 2 for token 2) +- token_id: 2 + repo_id: 3 + created_unix: 1772158384 diff --git a/models/auth/access_token.go b/models/auth/access_token.go index 85b89055e1..3311769573 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -224,15 +224,23 @@ func (opts ListAccessTokensOptions) ToOrders() string { // DeleteAccessTokenByID deletes access token by given ID. func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error { - cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{ - UID: userID, + return db.WithTx(ctx, func(ctx context.Context) error { + if err := db.DeleteBeans(ctx, + &AccessTokenResourceRepo{TokenID: id}, + ); err != nil { + return fmt.Errorf("DeleteBeans: %w", err) + } + + cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{ + UID: userID, + }) + if err != nil { + return err + } else if cnt != 1 { + return ErrAccessTokenNotExist{} + } + return nil }) - if err != nil { - return err - } else if cnt != 1 { - return ErrAccessTokenNotExist{} - } - return nil } // RegenerateAccessTokenByID regenerates access token by given ID. diff --git a/models/auth/access_token_resource.go b/models/auth/access_token_resource.go index 1e29ac4765..bf98c52b69 100644 --- a/models/auth/access_token_resource.go +++ b/models/auth/access_token_resource.go @@ -34,3 +34,35 @@ func GetRepositoriesAccessibleWithToken(ctx context.Context, accessTokenID int64 } return resources, nil } + +func GetRepositoriesAccessibleWithTokens(ctx context.Context, accessTokens []*AccessToken) (map[int64][]*AccessTokenResourceRepo, error) { + accessTokenIDs := make([]int64, len(accessTokens)) + for i, at := range accessTokens { + accessTokenIDs[i] = at.ID + } + + var resources []*AccessTokenResourceRepo + err := db.GetEngine(ctx). + In("token_id", accessTokenIDs). + Find(&resources) + if err != nil { + return nil, err + } + retval := make(map[int64][]*AccessTokenResourceRepo) + for _, resource := range resources { + retval[resource.TokenID] = append(retval[resource.TokenID], resource) + } + return retval, nil +} + +func InsertAccessTokenResourceRepos(ctx context.Context, accessTokenID int64, resources []*AccessTokenResourceRepo) error { + return db.WithTx(ctx, func(ctx context.Context) error { + for _, resourceRepo := range resources { + resourceRepo.TokenID = accessTokenID + if err := db.Insert(ctx, resourceRepo); err != nil { + return err + } + } + return nil + }) +} diff --git a/models/auth/access_token_resource_test.go b/models/auth/access_token_resource_test.go index 722dcc070d..d8cd4e286a 100644 --- a/models/auth/access_token_resource_test.go +++ b/models/auth/access_token_resource_test.go @@ -7,7 +7,6 @@ import ( "testing" auth_model "forgejo.org/models/auth" - "forgejo.org/models/db" "forgejo.org/models/unittest" "github.com/stretchr/testify/assert" @@ -19,13 +18,13 @@ func TestGetRepositoriesAccessibleWithToken(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) t.Run("No Resources", func(t *testing.T) { - resources, err := auth_model.GetRepositoriesAccessibleWithToken(db.DefaultContext, 999) + resources, err := auth_model.GetRepositoriesAccessibleWithToken(t.Context(), 999) require.NoError(t, err) assert.Empty(t, resources) }) t.Run("Has Resources", func(t *testing.T) { - resources, err := auth_model.GetRepositoriesAccessibleWithToken(db.DefaultContext, 3) + resources, err := auth_model.GetRepositoriesAccessibleWithToken(t.Context(), 3) require.NoError(t, err) require.Len(t, resources, 3) @@ -39,3 +38,86 @@ func TestGetRepositoriesAccessibleWithToken(t *testing.T) { assert.Contains(t, repoIDs, int64(3)) }) } + +func TestGetRepositoriesAccessibleWithTokens(t *testing.T) { + defer unittest.OverrideFixtures("models/auth/TestGetRepositoriesAccessibleWithTokens")() + require.NoError(t, unittest.PrepareTestDatabase()) + + token1 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 1}) + token2 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 2}) + token3 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 3}) + + t.Run("No Tokens", func(t *testing.T) { + resources, err := auth_model.GetRepositoriesAccessibleWithTokens(t.Context(), []*auth_model.AccessToken{}) + require.NoError(t, err) + assert.Empty(t, resources) + }) + + t.Run("Multiple Access Tokens", func(t *testing.T) { + resources, err := auth_model.GetRepositoriesAccessibleWithTokens(t.Context(), []*auth_model.AccessToken{token1, token2, token3}) + require.NoError(t, err) + + repos1, ok := resources[token1.ID] + assert.False(t, ok) + assert.Empty(t, repos1) + + repos2, ok := resources[token2.ID] + assert.True(t, ok) + assert.Len(t, repos2, 2) + + repos3, ok := resources[token3.ID] + assert.True(t, ok) + assert.Len(t, repos3, 3) + }) +} + +func TestInsertAccessTokenResourceRepos(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token1 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 1}) + token2 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 2}) + token3 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 3}) + + t.Run("blank insert", func(t *testing.T) { + err := auth_model.InsertAccessTokenResourceRepos(t.Context(), token1.ID, nil) + require.NoError(t, err) + }) + + t.Run("multiple insert", func(t *testing.T) { + resRepo1 := &auth_model.AccessTokenResourceRepo{ + TokenID: token2.ID, + RepoID: 1, + } + resRepo3 := &auth_model.AccessTokenResourceRepo{ + TokenID: token2.ID, + RepoID: 3, + } + err := auth_model.InsertAccessTokenResourceRepos(t.Context(), token2.ID, + []*auth_model.AccessTokenResourceRepo{resRepo1, resRepo3}) + require.NoError(t, err) + + unittest.AssertCount(t, &auth_model.AccessTokenResourceRepo{TokenID: token2.ID}, 2) + }) + + t.Run("in tx", func(t *testing.T) { + // Pre-condition: count is 0. + unittest.AssertCount(t, &auth_model.AccessTokenResourceRepo{TokenID: token3.ID}, 0) + + // Verify that InsertAccessTokenResourceRepos performs inserts in a TX by having a second one with an invalid + // RepoID, causing a foreign key violation + resRepo1 := &auth_model.AccessTokenResourceRepo{ + TokenID: token3.ID, + RepoID: 1, + } + resRepo3 := &auth_model.AccessTokenResourceRepo{ + TokenID: token3.ID, + RepoID: 30000, // invalid + } + err := auth_model.InsertAccessTokenResourceRepos(t.Context(), token3.ID, + []*auth_model.AccessTokenResourceRepo{resRepo1, resRepo3}) + require.ErrorContains(t, err, "foreign key") + + // Count remains 0; the first record was not inserted. + unittest.AssertCount(t, &auth_model.AccessTokenResourceRepo{TokenID: token3.ID}, 0) + }) +} diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 01cbf26f61..a8e8249f18 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -436,3 +436,12 @@ type UpdateRepoAvatarOption struct { // image must be base64 encoded Image string `json:"image" binding:"Required"` } + +type RepoTargetOption struct { + // Name of user or organisation that owns the repository + // required: true + Owner string `json:"owner"` + // Name of repository + // required: true + Name string `json:"name"` +} diff --git a/modules/structs/user_app.go b/modules/structs/user_app.go index a7504085fd..357f3aed0a 100644 --- a/modules/structs/user_app.go +++ b/modules/structs/user_app.go @@ -16,6 +16,9 @@ type AccessToken struct { Token string `json:"sha1"` TokenLastEight string `json:"token_last_eight"` Scopes []string `json:"scopes"` + // Indicates that an access token only has access to the specified repositories. Will be null if the access token + // is not limited to a set of specified repositories. + Repositories []*RepositoryMeta `json:"repositories"` } // AccessTokenList represents a list of API access token. @@ -28,6 +31,8 @@ type CreateAccessTokenOption struct { Name string `json:"name" binding:"Required"` // example: ["all", "read:activitypub","read:issue", "write:misc", "read:notification", "read:organization", "read:package", "read:repository", "read:user"] Scopes []string `json:"scopes"` + // If provided and not-empty, creates an access token with access only to specified repositories. + Repositories []*RepoTargetOption `json:"repositories"` } // CreateOAuth2ApplicationOptions holds options to create an oauth2 application diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 9047ca393c..ff67abe384 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -395,6 +395,9 @@ "packages.owner.settings.chef.title": "Chef registry", "packages.owner.settings.chef.keypair": "Generate key pair", "packages.owner.settings.chef.keypair.description": "Requests sent to the Chef registry must be cryptographically signed as a means of authentication. When generating a keypair, only the public key is stored on Forgejo. The private key is provided to you to be used with knife. Generating a new keypair will overwrite the previous one.", + "access_token.error.specified_repos_none": "Access tokens with specified repositories must have at least one repository.", + "access_token.error.specified_repos_and_public_only": "Access tokens with specified repositories cannot be combined with the public-only scope.", + "access_token.error.specified_repos_and_invalid_scope": "Access tokens with specified repositories can only be used with the read:issue, write:issue, read:repository, and write:repository scopes.", "actions.status.diagnostics.waiting": { "one": "Waiting for a runner with the following label: %s", "other": "Waiting for a runner with the following labels: %s" diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index a75c389a34..b223d35332 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -5,17 +5,25 @@ package user import ( + "cmp" + stdCtx "context" "errors" "fmt" "net/http" + "slices" "strconv" "strings" auth_model "forgejo.org/models/auth" "forgejo.org/models/db" + access_model "forgejo.org/models/perm/access" + repo_model "forgejo.org/models/repo" + "forgejo.org/modules/container" + "forgejo.org/modules/optional" api "forgejo.org/modules/structs" "forgejo.org/modules/web" "forgejo.org/routers/api/v1/utils" + "forgejo.org/services/authz" "forgejo.org/services/context" "forgejo.org/services/convert" ) @@ -57,6 +65,45 @@ func ListAccessTokens(ctx *context.APIContext) { return } + // Load all the AccessTokenResourceRepo for the tokens that we're returning: + allRepoIDs := container.Set[int64]{} + repoResourcesByTokenID, err := auth_model.GetRepositoriesAccessibleWithTokens(ctx, tokens) + if err != nil { + ctx.InternalServerError(err) + return + } + + // Load all the Repository models that are referenced by the AccessTokenResourceRepo's: + for _, repoResources := range repoResourcesByTokenID { + for _, repoResource := range repoResources { + allRepoIDs.Add(repoResource.RepoID) + } + } + reposByID, err := repo_model.GetRepositoriesMapByIDs(ctx, allRepoIDs.Slice()) + if err != nil { + ctx.InternalServerError(err) + return + } + + // Prepare a lookup map to access the repositories by token ID: + reposByTokenID := make(map[int64][]*api.RepositoryMeta) + for tokenID, repoResources := range repoResourcesByTokenID { + for _, repoResource := range repoResources { + repo, ok := reposByID[repoResource.RepoID] + if !ok { + // Shouldn't be possible with the foreign key on `AccessTokenResourceRepo` to the repository table. + ctx.Error(http.StatusInternalServerError, "reposById", "missing repository") + return + } + reposByTokenID[tokenID] = append(reposByTokenID[tokenID], &api.RepositoryMeta{ + ID: repo.ID, + Name: repo.Name, + Owner: repo.OwnerName, + FullName: repo.FullName(), + }) + } + } + apiTokens := make([]*api.AccessToken, len(tokens)) for i := range tokens { apiTokens[i] = &api.AccessToken{ @@ -64,13 +111,32 @@ func ListAccessTokens(ctx *context.APIContext) { Name: tokens[i].Name, TokenLastEight: tokens[i].TokenLastEight, Scopes: tokens[i].Scope.StringSlice(), + Repositories: reposByTokenID[tokens[i].ID], } + // Provide a consistent sort order on repositories, helpful for test consistency. Hard to do any earlier + // because of the bulk loading maps. + slices.SortFunc(apiTokens[i].Repositories, func(a, b *api.RepositoryMeta) int { + return cmp.Compare(a.ID, b.ID) + }) } ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, &apiTokens) } +func translateAccessTokenValidationError(ctx *context.Base, err error) optional.Option[string] { + switch { + case errors.Is(err, authz.ErrSpecifiedReposNone): + return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_none")) + case errors.Is(err, authz.ErrSpecifiedReposNoPublicOnly): + return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_public_only")) + case errors.Is(err, authz.ErrSpecifiedReposInvalidScope): + return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_invalid_scope")) + default: + return optional.None[string]() + } +} + // CreateAccessToken creates an access token func CreateAccessToken(ctx *context.APIContext) { // swagger:operation POST /users/{username}/tokens user userCreateToken @@ -128,11 +194,68 @@ func CreateAccessToken(ctx *context.APIContext) { } t.Scope = scope - // maintain legacy behaviour until new API options are added -- token has access to all resources, is not - // fine-grained - t.ResourceAllRepos = true + var resourceRepos []*auth_model.AccessTokenResourceRepo + var tokenRepositories []*api.RepositoryMeta - if err := auth_model.NewAccessToken(ctx, t); err != nil { + if len(form.Repositories) != 0 { + repos := make([]*repo_model.Repository, len(form.Repositories)) + for i, repoTarget := range form.Repositories { + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoTarget.Owner, repoTarget.Name) + if err != nil && repo_model.IsErrRepoNotExist(err) { + ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName", fmt.Errorf("repository %s/%s does not exist", repoTarget.Owner, repoTarget.Name)) + return + } else if err != nil { + ctx.ServerError("GetRepositoryByOwnerAndName", err) + return + } + permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer) + if err != nil { + ctx.ServerError("GetUserRepoPermissionWithReducer", err) + return + } else if !permission.HasAccess() { + // Prevent data existence probing -- ensure this error is the exact same as the !IsErrRepoNotExist case above + ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName", fmt.Errorf("repository %s/%s does not exist", repoTarget.Owner, repoTarget.Name)) + return + } + repos[i] = repo + } + + for _, repo := range repos { + resourceRepos = append(resourceRepos, &auth_model.AccessTokenResourceRepo{RepoID: repo.ID}) + tokenRepositories = append(tokenRepositories, &api.RepositoryMeta{ + ID: repo.ID, + Name: repo.Name, + Owner: repo.OwnerName, + FullName: repo.FullName(), + }) + } + + t.ResourceAllRepos = false + } else { + // token has access to all repository resources + t.ResourceAllRepos = true + } + + if err := authz.ValidateAccessToken(t, resourceRepos); err != nil { + s := translateAccessTokenValidationError(ctx.Base, err) + if has, str := s.Get(); has { + ctx.Error(http.StatusBadRequest, "ValidateAccessToken", str) + return + } + ctx.ServerError("ValidateAccessToken", err) + return + } + + err = db.WithTx(ctx, func(ctx stdCtx.Context) error { + if err := auth_model.NewAccessToken(ctx, t); err != nil { + return nil + } + if err := auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos); err != nil { + return nil + } + return nil + }) + if err != nil { ctx.Error(http.StatusInternalServerError, "NewAccessToken", err) return } @@ -142,6 +265,7 @@ func CreateAccessToken(ctx *context.APIContext) { ID: t.ID, TokenLastEight: t.TokenLastEight, Scopes: t.Scope.StringSlice(), + Repositories: tokenRepositories, }) } diff --git a/services/authz/access_token.go b/services/authz/access_token.go index 1d7b09e91e..207fdd6546 100644 --- a/services/authz/access_token.go +++ b/services/authz/access_token.go @@ -5,6 +5,7 @@ package authz import ( "context" + "errors" "fmt" auth_model "forgejo.org/models/auth" @@ -26,3 +27,56 @@ func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_mode } return &SpecificReposAuthorizationReducer{resourceRepos: repos}, nil } + +// A locale lookup string for the error -- eg. `access_token.error.invalid_something` +type AccessTokenValidationFailure string + +// Validate that an access token's state is valid for creation. For example, that it doesn't have a conflicting set of +// resources (public-only and specific repositories), and other similar checks. +func ValidateAccessToken(token *auth_model.AccessToken, repoResources []*auth_model.AccessTokenResourceRepo) error { + // Other validations may be added here in the future. + return validateRepositoryResource(token, repoResources) +} + +var ( + ErrSpecifiedReposNone = errors.New("specified repository access token: must have at least one repository") + ErrSpecifiedReposNoPublicOnly = errors.New("specified repository access token: cannot be combined with public-only scope") + ErrSpecifiedReposInvalidScope = errors.New("specified repository access token: invalid scope") +) + +func validateRepositoryResource(token *auth_model.AccessToken, repoResources []*auth_model.AccessTokenResourceRepo) error { + // Access tokens with broad access to all resources don't have any relevant validation rules to apply. + if token.ResourceAllRepos { + return nil + } + + // Repo-specific access token must have at least one repository. + if len(repoResources) == 0 { + return ErrSpecifiedReposNone + } + + // Can't have public-only and specified repos -- that's a combination that doesn't make sense. + if publicOnly, err := token.Scope.PublicOnly(); err != nil { + return err + } else if publicOnly { + return ErrSpecifiedReposNoPublicOnly + } + + // Repo-specific access tokens are only effective at restricting permissions if they are limited to the scopes that + // support repositories as a resource. For example, if you had a repo-specific token but then gave it + // `write:organization`, it would be able to do operations like delete an organization -- permission checks on the + // repository resources wouldn't be applicable to the organization resources. + for _, scope := range token.Scope.StringSlice() { + switch auth_model.AccessTokenScope(scope) { + case auth_model.AccessTokenScopeReadIssue, + auth_model.AccessTokenScopeWriteIssue, + auth_model.AccessTokenScopeReadRepository, + auth_model.AccessTokenScopeWriteRepository: + continue + default: + return fmt.Errorf("%w: cannot be combined with scope %s", ErrSpecifiedReposInvalidScope, scope) + } + } + + return nil +} diff --git a/services/authz/access_token_test.go b/services/authz/access_token_test.go index d037add76f..8219e2a057 100644 --- a/services/authz/access_token_test.go +++ b/services/authz/access_token_test.go @@ -4,6 +4,7 @@ package authz import ( + "strings" "testing" "forgejo.org/models/auth" @@ -44,3 +45,55 @@ func TestGetAuthorizationReducerForAccessToken(t *testing.T) { assert.EqualValues(t, 1, specific.resourceRepos[0].RepoID) }) } + +func TestValidateAccessToken(t *testing.T) { + t.Run("valid - all access", func(t *testing.T) { + token := &auth.AccessToken{ + ResourceAllRepos: true, + Scope: auth.AccessTokenScopeReadRepository, + } + err := ValidateAccessToken(token, nil) + require.NoError(t, err) + }) + + t.Run("valid - specified repos", func(t *testing.T) { + token := &auth.AccessToken{ + ResourceAllRepos: false, + Scope: auth.AccessTokenScopeReadRepository, + } + resources := []*auth.AccessTokenResourceRepo{{RepoID: 12}} + err := ValidateAccessToken(token, resources) + require.NoError(t, err) + }) + + t.Run("invalid - no specified repos", func(t *testing.T) { + token := &auth.AccessToken{ + ResourceAllRepos: false, + Scope: auth.AccessTokenScopeReadRepository, + } + resources := []*auth.AccessTokenResourceRepo{} + err := ValidateAccessToken(token, resources) + require.ErrorIs(t, err, ErrSpecifiedReposNone) + }) + + t.Run("invalid - specified repos & public-only", func(t *testing.T) { + token := &auth.AccessToken{ + ResourceAllRepos: false, + Scope: auth.AccessTokenScope(strings.Join([]string{string(auth.AccessTokenScopePublicOnly), string(auth.AccessTokenScopeReadRepository)}, ",")), + } + resources := []*auth.AccessTokenResourceRepo{{RepoID: 12}} + err := ValidateAccessToken(token, resources) + require.ErrorIs(t, err, ErrSpecifiedReposNoPublicOnly) + }) + + t.Run("invalid - specified repos unsupported scopes", func(t *testing.T) { + token := &auth.AccessToken{ + ResourceAllRepos: false, + Scope: auth.AccessTokenScopeReadAdmin, + } + resources := []*auth.AccessTokenResourceRepo{{RepoID: 12}} + err := ValidateAccessToken(token, resources) + require.ErrorIs(t, err, ErrSpecifiedReposInvalidScope) + require.ErrorContains(t, err, string(auth.AccessTokenScopeReadAdmin)) + }) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f155e91f3c..11bde671df 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22167,6 +22167,14 @@ "type": "string", "x-go-name": "Name" }, + "repositories": { + "description": "Indicates that an access token only has access to the specified repositories. Will be null if the access token\nis not limited to a set of specified repositories.", + "type": "array", + "items": { + "$ref": "#/definitions/RepositoryMeta" + }, + "x-go-name": "Repositories" + }, "scopes": { "type": "array", "items": { @@ -23528,6 +23536,14 @@ "type": "string", "x-go-name": "Name" }, + "repositories": { + "description": "If provided and not-empty, creates an access token with access only to specified repositories.", + "type": "array", + "items": { + "$ref": "#/definitions/RepoTargetOption" + }, + "x-go-name": "Repositories" + }, "scopes": { "type": "array", "items": { @@ -28742,6 +28758,26 @@ }, "x-go-package": "forgejo.org/modules/structs" }, + "RepoTargetOption": { + "type": "object", + "required": [ + "owner", + "name" + ], + "properties": { + "name": { + "description": "Name of repository", + "type": "string", + "x-go-name": "Name" + }, + "owner": { + "description": "Name of user or organisation that owns the repository", + "type": "string", + "x-go-name": "Owner" + } + }, + "x-go-package": "forgejo.org/modules/structs" + }, "RepoTopicOptions": { "description": "RepoTopicOptions a collection of repo topic names", "type": "object", diff --git a/tests/integration/api_token_test.go b/tests/integration/api_token_test.go index 2beadfacc2..1b61885a9b 100644 --- a/tests/integration/api_token_test.go +++ b/tests/integration/api_token_test.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "testing" auth_model "forgejo.org/models/auth" @@ -38,17 +39,78 @@ func TestAPIGetTokens(t *testing.T) { defer tests.PrepareTestEnv(t)() user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - // with basic auth... - req := NewRequest(t, "GET", "/api/v1/users/user2/tokens"). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusOK) + t.Run("GET w/ basic auth", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - // ... or with a token. - newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) - req = NewRequest(t, "GET", "/api/v1/users/user2/tokens"). - AddTokenAuth(newAccessToken.Token) - MakeRequest(t, req, http.StatusOK) - deleteAPIAccessToken(t, newAccessToken, user) + // with basic auth... + req := NewRequest(t, "GET", "/api/v1/users/user2/tokens"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + var accessTokens api.AccessTokenList + DecodeJSON(t, resp, &accessTokens) + + require.Len(t, accessTokens, 1) + at := accessTokens[0] + assert.EqualValues(t, 3, at.ID) + assert.Equal(t, "Token A", at.Name) + assert.Equal(t, []string{""}, at.Scopes) + assert.Empty(t, at.Token) + assert.Equal(t, "69d28c91", at.TokenLastEight) + assert.Nil(t, at.Repositories) + }) + + t.Run("GET w/ token auth", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // ... or with a token. + newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + req := NewRequest(t, "GET", "/api/v1/users/user2/tokens"). + AddTokenAuth(newAccessToken.Token) + resp := MakeRequest(t, req, http.StatusOK) + var accessTokens api.AccessTokenList + DecodeJSON(t, resp, &accessTokens) + deleteAPIAccessToken(t, newAccessToken, user) + }) + + t.Run("GET fine-grained token", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2", + []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadUser}, + []int64{2, 3}, + ) + + req := NewRequest(t, "GET", "/api/v1/users/user2/tokens"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + var accessTokens api.AccessTokenList + DecodeJSON(t, resp, &accessTokens) + + found := false + for _, token := range accessTokens { + if strings.HasSuffix(repo2OnlyToken, token.TokenLastEight) { + found = true + assert.Len(t, token.Repositories, 2) + + repo2 := token.Repositories[0] + assert.Equal(t, &api.RepositoryMeta{ + ID: 2, + Name: "repo2", + Owner: "user2", + FullName: "user2/repo2", + }, repo2) + + repo3 := token.Repositories[1] + assert.Equal(t, &api.RepositoryMeta{ + ID: 3, + Name: "repo3", + Owner: "org3", + FullName: "org3/repo3", + }, repo3) + } + } + assert.True(t, found) + }) } // TestAPIDeleteMissingToken ensures that error is thrown when token not found @@ -658,8 +720,111 @@ func TestAPITokenCreation(t *testing.T) { "name": "new-new-token", "scopes": []auth_model.AccessTokenScope{auth_model.AccessTokenScopeWriteUser}, }) - req.Request.Header.Set("Authorization", "basic "+base64.StdEncoding.EncodeToString([]byte("user4:"+userPassword))) + req.AddBasicAuth("user4") - MakeRequest(t, req, http.StatusCreated) + resp := MakeRequest(t, req, http.StatusCreated) + var token api.AccessToken + DecodeJSON(t, resp, &token) + }) + + t.Run("repo-specific", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("valid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{ + Name: "even-newer-token", + Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)}, + Repositories: []*api.RepoTargetOption{ + { + Owner: "user2", + Name: "repo2", + }, + }, + }) + req.AddBasicAuth("user2") + + resp := MakeRequest(t, req, http.StatusCreated) + var token api.AccessToken + DecodeJSON(t, resp, &token) + assert.NotEmpty(t, token.Repositories) + }) + + t.Run("target other user's private repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{ + Name: "not-a-valid-token", + Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)}, + Repositories: []*api.RepoTargetOption{ + { + Owner: "user10", + Name: "repo7", // private repo owned by another user + }, + }, + }) + req.AddBasicAuth("user2") + MakeRequest(t, req, http.StatusBadRequest) + }) + + t.Run("target invalid repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{ + Name: "not-a-valid-token", + Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)}, + Repositories: []*api.RepoTargetOption{ + { + // doesn't exist: + Owner: "user10000", + Name: "repo70000", + }, + }, + }) + req.AddBasicAuth("user2") + MakeRequest(t, req, http.StatusBadRequest) + }) + + t.Run("invalid scopes", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{ + Name: "not-a-valid-token", + Scopes: []string{string(auth_model.AccessTokenScopeReadAdmin)}, + Repositories: []*api.RepoTargetOption{ + { + Owner: "user2", + Name: "repo2", + }, + }, + }) + req.AddBasicAuth("user2") + MakeRequest(t, req, http.StatusBadRequest) + }) }) } + +func TestAPITokenDelete(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{ + Name: "delete-this-token", + Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)}, + Repositories: []*api.RepoTargetOption{ + { + Owner: "user2", + Name: "repo2", + }, + }, + }) + req.AddBasicAuth("user2") + + resp := MakeRequest(t, req, http.StatusCreated) + var token api.AccessToken + DecodeJSON(t, resp, &token) + + unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: token.ID}) + + req = NewRequestf(t, "DELETE", "/api/v1/users/user2/tokens/%d", token.ID) + req.AddBasicAuth("user2") + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: token.ID}) +}