feat: read, create, & delete repo-specific access tokens via API (#11504)
This PR is part of a series (#11311). Adds support for reading and creating repo-secific access tokens through the API via the `GET /users/{username}/tokens`, `POST /users/{username}/tokens`, and `DELETE /users/{username}/tokens/{id}` APIs. Validation rules are included to [restrict repo-specific access tokens to specific scopes](https://codeberg.org/forgejo/design/issues/50#issuecomment-11093951). ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. <!--start release-notes-assistant--> ## Release notes <!--URL:https://codeberg.org/forgejo/forgejo--> - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/11504): <!--number 11504 --><!--line 0 --><!--description cmVhZCwgY3JlYXRlLCAmIGRlbGV0ZSByZXBvLXNwZWNpZmljIGFjY2VzcyB0b2tlbnMgdmlhIEFQSQ==-->read, create, & delete repo-specific access tokens via API<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11504 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net> Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
committed by
Mathieu Fenniak
parent
48d2af5561
commit
2db6210f69
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+128
-4
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+36
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user