mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
feat(api): add admin routes to manage user access tokens (#12323)
# Feature Request: Admin API route to manage access tokens for any user
## Problem
The existing API route to create access tokens (POST /api/v1/users/{username}/tokens) requires Basic authentication (username + password) via the reqBasicOrRevProxyAuth() middleware. This is by design: a token should not be created from another token.
However, this creates a blocker for environments where Basic authentication is disabled (ENABLE_BASIC_AUTHENTICATION = false), typically when authentication is delegated to an external SSO provider (e.g., OpenID Connect).
In such setups, bot/service accounts are provisioned by an external system that needs to:
Create a user via POST /api/v1/admin/users (works fine with an admin token)
Create an access token for that user (currently impossible without Basic auth or direct CLI/DB access)
The only workaround today is to SSH into the Forgejo server and run:
This is not suitable when the provisioning system has no direct access to the Forgejo host.
## Proposed solution
Add new admin-only API routes under the existing /api/v1/admin/users/{username} group to manage access tokens:
| Method | Route | Description |
|:-------- |:--------:| --------:|
| GET | /api/v1/admin/users/{username}/tokens | List access tokens for a user|
|POST | /api/v1/admin/users/{username}/tokens | Create an access token for a user|
|DELETE | /api/v1/admin/users/{username}/tokens/{id} | Delete an access token for a user|
These routes would:
Require a site admin token (reqToken() + reqSiteAdmin()) — no Basic auth needed
Use the AccessTokenScopeCategoryAdmin token scope
Reuse the existing handler logic from user.CreateAccessToken / user.ListAccessTokens / user.DeleteAccessToken
Accept the same request/response payloads as the existing user-facing routes
### Why this belongs in the admin API
It follows the existing pattern: admins can already create users, repos, orgs, SSH keys, and emails for any user via the admin API
It does not weaken security: only site administrators can call it, and it requires a valid admin-scoped token
It fills a gap: the admin CLI command forgejo admin user generate-access-token already provides this capability, but only locally
<!--start release-notes-assistant-->
## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
- [PR](https://codeberg.org/forgejo/forgejo/pulls/12323): <!--number 12323 --><!--line 0 --><!--description ZmVhdChhcGkpOiBhZGQgYWRtaW4gcm91dGVzIHRvIG1hbmFnZSB1c2VyIGFjY2VzcyB0b2tlbnM=-->feat(api): add admin routes to manage user access tokens<!--description-->
<!--end release-notes-assistant-->
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12323
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
parent
03312e4f46
commit
ba1c3e0288
6 changed files with 808 additions and 205 deletions
107
routers/api/v1/admin/user_token.go
Normal file
107
routers/api/v1/admin/user_token.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/routers/api/v1/utils"
|
||||||
|
"forgejo.org/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListUserAccessTokens lists all access tokens for a given user.
|
||||||
|
// This endpoint is admin-only and does not require Basic auth.
|
||||||
|
func ListUserAccessTokens(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /admin/users/{username}/tokens admin adminListUserAccessTokens
|
||||||
|
// ---
|
||||||
|
// summary: List the specified user's access tokens
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of user
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: page
|
||||||
|
// in: query
|
||||||
|
// description: page number of results to return (1-based)
|
||||||
|
// type: integer
|
||||||
|
// - name: limit
|
||||||
|
// in: query
|
||||||
|
// description: page size of results
|
||||||
|
// type: integer
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/AccessTokenList"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
utils.ListAccessTokens(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserAccessToken creates a new access token for a given user.
|
||||||
|
// This endpoint is admin-only and does not require Basic auth.
|
||||||
|
func CreateUserAccessToken(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /admin/users/{username}/tokens admin adminCreateUserAccessToken
|
||||||
|
// ---
|
||||||
|
// summary: Create an access token for the specified user
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of user
|
||||||
|
// required: true
|
||||||
|
// type: string
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/CreateAccessTokenOption"
|
||||||
|
// responses:
|
||||||
|
// "201":
|
||||||
|
// "$ref": "#/responses/AccessToken"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
utils.CreateAccessToken(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserAccessToken deletes an access token for a given user.
|
||||||
|
// This endpoint is admin-only and does not require Basic auth.
|
||||||
|
func DeleteUserAccessToken(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /admin/users/{username}/tokens/{token} admin adminDeleteUserAccessToken
|
||||||
|
// ---
|
||||||
|
// summary: Delete an access token for the specified user
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of user
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: token
|
||||||
|
// in: path
|
||||||
|
// description: token to be deleted, identified by ID and if not available by name
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
|
||||||
|
utils.DeleteAccessToken(ctx)
|
||||||
|
}
|
||||||
|
|
@ -1697,6 +1697,11 @@ func Routes() *web.Route {
|
||||||
m.Combo("/emails").
|
m.Combo("/emails").
|
||||||
Get(admin.ListUserEmails).
|
Get(admin.ListUserEmails).
|
||||||
Delete(bind(api.DeleteEmailOption{}), admin.DeleteUserEmails)
|
Delete(bind(api.DeleteEmailOption{}), admin.DeleteUserEmails)
|
||||||
|
m.Group("/tokens", func() {
|
||||||
|
m.Combo("").Get(admin.ListUserAccessTokens).
|
||||||
|
Post(bind(api.CreateAccessTokenOption{}), admin.CreateUserAccessToken)
|
||||||
|
m.Combo("/{id}").Delete(admin.DeleteUserAccessToken)
|
||||||
|
})
|
||||||
if setting.Quota.Enabled {
|
if setting.Quota.Enabled {
|
||||||
m.Group("/quota", func() {
|
m.Group("/quota", func() {
|
||||||
m.Get("", admin.GetUserQuota)
|
m.Get("", admin.GetUserQuota)
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,13 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
stdCtx "context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
auth_model "forgejo.org/models/auth"
|
auth_model "forgejo.org/models/auth"
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
access_model "forgejo.org/models/perm/access"
|
|
||||||
repo_model "forgejo.org/models/repo"
|
|
||||||
api "forgejo.org/modules/structs"
|
api "forgejo.org/modules/structs"
|
||||||
"forgejo.org/modules/web"
|
"forgejo.org/modules/web"
|
||||||
"forgejo.org/routers/api/v1/utils"
|
"forgejo.org/routers/api/v1/utils"
|
||||||
"forgejo.org/routers/web/shared/user"
|
|
||||||
"forgejo.org/services/authz"
|
|
||||||
"forgejo.org/services/context"
|
"forgejo.org/services/context"
|
||||||
"forgejo.org/services/convert"
|
"forgejo.org/services/convert"
|
||||||
)
|
)
|
||||||
|
|
@ -56,63 +45,7 @@ func ListAccessTokens(ctx *context.APIContext) {
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: utils.GetListOptions(ctx)}
|
utils.ListAccessTokens(ctx)
|
||||||
|
|
||||||
tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts)
|
|
||||||
if err != nil {
|
|
||||||
ctx.InternalServerError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all the AccessTokenResourceRepo for the tokens that we're returning:
|
|
||||||
repoModelsByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens,
|
|
||||||
func(repo *repo_model.Repository) (bool, error) {
|
|
||||||
// Repos associated with a repo-specific access token should already be visible to the token owner, but it's
|
|
||||||
// possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that
|
|
||||||
// repo if so.
|
|
||||||
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return permission.HasAccess(), nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ctx.InternalServerError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Convert map[int64]*Repository -> map[int64]*RepositoryMeta...
|
|
||||||
reposByTokenID := make(map[int64][]*api.RepositoryMeta)
|
|
||||||
for tokenID, repoModels := range repoModelsByTokenID {
|
|
||||||
repos := make([]*api.RepositoryMeta, len(repoModels))
|
|
||||||
for i, repo := range repoModels {
|
|
||||||
repos[i] = &api.RepositoryMeta{
|
|
||||||
ID: repo.ID,
|
|
||||||
Name: repo.Name,
|
|
||||||
Owner: repo.OwnerName,
|
|
||||||
FullName: repo.FullName(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reposByTokenID[tokenID] = repos
|
|
||||||
}
|
|
||||||
|
|
||||||
apiTokens := make([]*api.AccessToken, len(tokens))
|
|
||||||
for i := range tokens {
|
|
||||||
apiTokens[i] = &api.AccessToken{
|
|
||||||
ID: tokens[i].ID,
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAccessToken creates an access token
|
// CreateAccessToken creates an access token
|
||||||
|
|
@ -144,104 +77,7 @@ func CreateAccessToken(ctx *context.APIContext) {
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
form := web.GetForm(ctx).(*api.CreateAccessTokenOption)
|
utils.CreateAccessToken(ctx)
|
||||||
|
|
||||||
t := &auth_model.AccessToken{
|
|
||||||
UID: ctx.ContextUser.ID,
|
|
||||||
Name: form.Name,
|
|
||||||
}
|
|
||||||
|
|
||||||
exist, err := auth_model.AccessTokenByNameExists(ctx, t)
|
|
||||||
if err != nil {
|
|
||||||
ctx.InternalServerError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if exist {
|
|
||||||
ctx.Error(http.StatusBadRequest, "AccessTokenByNameExists", errors.New("access token name has been used already"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusBadRequest, "AccessTokenScope.Normalize", fmt.Errorf("invalid access token scope provided: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if scope == "" {
|
|
||||||
ctx.Error(http.StatusBadRequest, "AccessTokenScope", "access token must have a scope")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Scope = scope
|
|
||||||
|
|
||||||
var resourceRepos []*auth_model.AccessTokenResourceRepo
|
|
||||||
var tokenRepositories []*api.RepositoryMeta
|
|
||||||
|
|
||||||
if form.Repositories != nil {
|
|
||||||
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 := user.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 err
|
|
||||||
}
|
|
||||||
return auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "NewAccessToken", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusCreated, &api.AccessToken{
|
|
||||||
Name: t.Name,
|
|
||||||
Token: t.Token,
|
|
||||||
ID: t.ID,
|
|
||||||
TokenLastEight: t.TokenLastEight,
|
|
||||||
Scopes: t.Scope.StringSlice(),
|
|
||||||
Repositories: tokenRepositories,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAccessToken deletes an access token
|
// DeleteAccessToken deletes an access token
|
||||||
|
|
@ -272,45 +108,7 @@ func DeleteAccessToken(ctx *context.APIContext) {
|
||||||
// "422":
|
// "422":
|
||||||
// "$ref": "#/responses/error"
|
// "$ref": "#/responses/error"
|
||||||
|
|
||||||
token := ctx.Params(":id")
|
utils.DeleteAccessToken(ctx)
|
||||||
tokenID, _ := strconv.ParseInt(token, 0, 64)
|
|
||||||
|
|
||||||
if tokenID == 0 {
|
|
||||||
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
|
|
||||||
Name: token,
|
|
||||||
UserID: ctx.ContextUser.ID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "ListAccessTokens", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch len(tokens) {
|
|
||||||
case 0:
|
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
case 1:
|
|
||||||
tokenID = tokens[0].ID
|
|
||||||
default:
|
|
||||||
ctx.Error(http.StatusUnprocessableEntity, "DeleteAccessTokenByID", fmt.Errorf("multiple matches for token name '%s'", token))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tokenID == 0 {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "Invalid TokenID", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
|
|
||||||
if auth_model.IsErrAccessTokenNotExist(err) {
|
|
||||||
ctx.NotFound()
|
|
||||||
} else {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "DeleteAccessTokenByID", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
|
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
|
||||||
|
|
|
||||||
234
routers/api/v1/utils/access_token.go
Normal file
234
routers/api/v1/utils/access_token.go
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
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"
|
||||||
|
api "forgejo.org/modules/structs"
|
||||||
|
"forgejo.org/modules/web"
|
||||||
|
"forgejo.org/routers/web/shared/user"
|
||||||
|
"forgejo.org/services/authz"
|
||||||
|
"forgejo.org/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteAccessToken deletes an access token for a user identified by ctx.ContextUser.
|
||||||
|
// Shared logic between user and admin token deletion endpoints.
|
||||||
|
func DeleteAccessToken(ctx *context.APIContext) {
|
||||||
|
token := ctx.Params(":id")
|
||||||
|
tokenID, _ := strconv.ParseInt(token, 0, 64)
|
||||||
|
|
||||||
|
if tokenID == 0 {
|
||||||
|
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
|
||||||
|
Name: token,
|
||||||
|
UserID: ctx.ContextUser.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "ListAccessTokens", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(tokens) {
|
||||||
|
case 0:
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
case 1:
|
||||||
|
tokenID = tokens[0].ID
|
||||||
|
default:
|
||||||
|
ctx.Error(http.StatusUnprocessableEntity, "DeleteAccessTokenByID", fmt.Errorf("multiple matches for token name '%s'", token))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokenID == 0 {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "Invalid TokenID", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
|
||||||
|
if auth_model.IsErrAccessTokenNotExist(err) {
|
||||||
|
ctx.NotFound()
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "DeleteAccessTokenByID", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAccessTokens lists access tokens for a user identified by ctx.ContextUser.
|
||||||
|
// Shared logic between user and admin token listing endpoints.
|
||||||
|
func ListAccessTokens(ctx *context.APIContext) {
|
||||||
|
opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: GetListOptions(ctx)}
|
||||||
|
|
||||||
|
tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
ctx.InternalServerError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all the AccessTokenResourceRepo for the tokens that we're returning:
|
||||||
|
repoModelsByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens,
|
||||||
|
func(repo *repo_model.Repository) (bool, error) {
|
||||||
|
// Repos associated with a repo-specific access token should already be visible to the token owner, but it's
|
||||||
|
// possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that
|
||||||
|
// repo if so.
|
||||||
|
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return permission.HasAccess(), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.InternalServerError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Convert map[int64]*Repository -> map[int64]*RepositoryMeta...
|
||||||
|
reposByTokenID := make(map[int64][]*api.RepositoryMeta)
|
||||||
|
for tokenID, repoModels := range repoModelsByTokenID {
|
||||||
|
repos := make([]*api.RepositoryMeta, len(repoModels))
|
||||||
|
for i, repo := range repoModels {
|
||||||
|
repos[i] = &api.RepositoryMeta{
|
||||||
|
ID: repo.ID,
|
||||||
|
Name: repo.Name,
|
||||||
|
Owner: repo.OwnerName,
|
||||||
|
FullName: repo.FullName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reposByTokenID[tokenID] = repos
|
||||||
|
}
|
||||||
|
|
||||||
|
apiTokens := make([]*api.AccessToken, len(tokens))
|
||||||
|
for i := range tokens {
|
||||||
|
apiTokens[i] = &api.AccessToken{
|
||||||
|
ID: tokens[i].ID,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAccessToken creates an access token for a user identified by ctx.ContextUser.
|
||||||
|
// Shared logic between user and admin token creation endpoints.
|
||||||
|
func CreateAccessToken(ctx *context.APIContext) {
|
||||||
|
form := web.GetForm(ctx).(*api.CreateAccessTokenOption)
|
||||||
|
|
||||||
|
t := &auth_model.AccessToken{
|
||||||
|
UID: ctx.ContextUser.ID,
|
||||||
|
Name: form.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
exist, err := auth_model.AccessTokenByNameExists(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
ctx.InternalServerError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exist {
|
||||||
|
ctx.Error(http.StatusBadRequest, "AccessTokenByNameExists", errors.New("access token name has been used already"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "AccessTokenScope.Normalize", fmt.Errorf("invalid access token scope provided: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scope == "" {
|
||||||
|
ctx.Error(http.StatusBadRequest, "AccessTokenScope", "access token must have a scope")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Scope = scope
|
||||||
|
|
||||||
|
var resourceRepos []*auth_model.AccessTokenResourceRepo
|
||||||
|
var tokenRepositories []*api.RepositoryMeta
|
||||||
|
|
||||||
|
if form.Repositories != nil {
|
||||||
|
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 := user.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 err
|
||||||
|
}
|
||||||
|
return auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "NewAccessToken", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusCreated, &api.AccessToken{
|
||||||
|
Name: t.Name,
|
||||||
|
Token: t.Token,
|
||||||
|
ID: t.ID,
|
||||||
|
TokenLastEight: t.TokenLastEight,
|
||||||
|
Scopes: t.Scope.StringSlice(),
|
||||||
|
Repositories: tokenRepositories,
|
||||||
|
})
|
||||||
|
}
|
||||||
129
templates/swagger/v1_json.tmpl
generated
129
templates/swagger/v1_json.tmpl
generated
|
|
@ -2043,6 +2043,135 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/users/{username}/tokens": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "List the specified user's access tokens",
|
||||||
|
"operationId": "adminListUserAccessTokens",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "username of user",
|
||||||
|
"name": "username",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "page number of results to return (1-based)",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "page size of results",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/AccessTokenList"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "Create an access token for the specified user",
|
||||||
|
"operationId": "adminCreateUserAccessToken",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "username of user",
|
||||||
|
"name": "username",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/CreateAccessTokenOption"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"$ref": "#/responses/AccessToken"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/users/{username}/tokens/{token}": {
|
||||||
|
"delete": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "Delete an access token for the specified user",
|
||||||
|
"operationId": "adminDeleteUserAccessToken",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "username of user",
|
||||||
|
"name": "username",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "token to be deleted, identified by ID and if not available by name",
|
||||||
|
"name": "token",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"$ref": "#/responses/empty"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/gitignore/templates": {
|
"/gitignore/templates": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|
|
||||||
330
tests/integration/api_admin_user_token_test.go
Normal file
330
tests/integration/api_admin_user_token_test.go
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
auth_model "forgejo.org/models/auth"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
api "forgejo.org/modules/structs"
|
||||||
|
"forgejo.org/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIAdminCreateUserAccessToken(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
||||||
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
||||||
|
|
||||||
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
|
||||||
|
t.Run("Create token for another user", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "admin-created-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newToken api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &newToken)
|
||||||
|
|
||||||
|
assert.Equal(t, "admin-created-token", newToken.Name)
|
||||||
|
assert.NotEmpty(t, newToken.Token)
|
||||||
|
assert.NotEmpty(t, newToken.TokenLastEight)
|
||||||
|
assert.Contains(t, newToken.Scopes, "all")
|
||||||
|
|
||||||
|
// Verify the token exists in DB
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{
|
||||||
|
ID: newToken.ID,
|
||||||
|
Name: newToken.Name,
|
||||||
|
UID: targetUser.ID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create token with duplicate name", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "admin-created-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create token without scopes", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "empty-scope-token",
|
||||||
|
Scopes: []string{},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create token with invalid scope", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "invalid-scope-token",
|
||||||
|
Scopes: []string{"invalid-scope"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create token for nonexistent user", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := "/api/v1/admin/users/nonexistentuser/tokens"
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "some-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Non-admin cannot create token", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "unauthorized-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(normalToken)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIAdminListUserAccessTokens(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
||||||
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
||||||
|
|
||||||
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
|
||||||
|
// First, create a token for the target user
|
||||||
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
||||||
|
Name: "list-test-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
t.Run("List tokens for user", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequest(t, "GET", listURL).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var tokens []*api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &tokens)
|
||||||
|
|
||||||
|
// user2 has at least the token we just created plus any fixture tokens
|
||||||
|
require.NotEmpty(t, tokens)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, tk := range tokens {
|
||||||
|
if tk.Name == "list-test-token" {
|
||||||
|
found = true
|
||||||
|
assert.NotEmpty(t, tk.TokenLastEight)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "should find the admin-created token in the list")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Non-admin cannot list tokens", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequest(t, "GET", listURL).AddTokenAuth(normalToken)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIAdminCreateRepoSpecificToken(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
||||||
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
||||||
|
|
||||||
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
|
||||||
|
t.Run("Create repo-specific token", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "admin-repo-specific-token",
|
||||||
|
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
|
||||||
|
Repositories: []*api.RepoTargetOption{
|
||||||
|
{
|
||||||
|
Owner: "user2",
|
||||||
|
Name: "repo2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newToken api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &newToken)
|
||||||
|
|
||||||
|
assert.Equal(t, "admin-repo-specific-token", newToken.Name)
|
||||||
|
assert.NotEmpty(t, newToken.Token)
|
||||||
|
assert.NotEmpty(t, newToken.Repositories)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create token targeting invalid repo", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
||||||
|
Name: "admin-invalid-repo-token",
|
||||||
|
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
|
||||||
|
Repositories: []*api.RepoTargetOption{
|
||||||
|
{
|
||||||
|
Owner: "user10000",
|
||||||
|
Name: "repo70000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("List repo-specific token returns repositories", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
// Create a repo-specific token
|
||||||
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
||||||
|
Name: "admin-list-repo-token",
|
||||||
|
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
|
||||||
|
Repositories: []*api.RepoTargetOption{
|
||||||
|
{
|
||||||
|
Owner: "user2",
|
||||||
|
Name: "repo2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
// List tokens and verify repositories are returned
|
||||||
|
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req = NewRequest(t, "GET", listURL).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var tokens []*api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &tokens)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, tk := range tokens {
|
||||||
|
if tk.Name == "admin-list-repo-token" {
|
||||||
|
found = true
|
||||||
|
assert.NotEmpty(t, tk.Repositories, "admin-listed token should have repositories populated")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "should find the repo-specific token in the admin list")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIAdminDeleteUserAccessToken(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
||||||
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
||||||
|
|
||||||
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
|
||||||
|
t.Run("Delete token by ID", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
// Create a token first
|
||||||
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
||||||
|
Name: "delete-by-id-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newToken api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &newToken)
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, newToken.ID)
|
||||||
|
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: newToken.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete token by name", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
// Create a token first
|
||||||
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
||||||
|
Name: "delete-by-name-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newToken api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &newToken)
|
||||||
|
|
||||||
|
// Delete by name
|
||||||
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%s", targetUser.Name, "delete-by-name-token")
|
||||||
|
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: newToken.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete nonexistent token", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, 999999)
|
||||||
|
req := NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Non-admin cannot delete token", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
// Create a token as admin
|
||||||
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
||||||
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
||||||
|
Name: "non-admin-delete-token",
|
||||||
|
Scopes: []string{"all"},
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var newToken api.AccessToken
|
||||||
|
DecodeJSON(t, resp, &newToken)
|
||||||
|
|
||||||
|
// Try to delete as non-admin
|
||||||
|
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
|
||||||
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, newToken.ID)
|
||||||
|
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(normalToken)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue