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").
|
||||
Get(admin.ListUserEmails).
|
||||
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 {
|
||||
m.Group("/quota", func() {
|
||||
m.Get("", admin.GetUserQuota)
|
||||
|
|
|
|||
|
|
@ -5,24 +5,13 @@
|
|||
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"
|
||||
api "forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/web"
|
||||
"forgejo.org/routers/api/v1/utils"
|
||||
"forgejo.org/routers/web/shared/user"
|
||||
"forgejo.org/services/authz"
|
||||
"forgejo.org/services/context"
|
||||
"forgejo.org/services/convert"
|
||||
)
|
||||
|
|
@ -56,63 +45,7 @@ func ListAccessTokens(ctx *context.APIContext) {
|
|||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: utils.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)
|
||||
utils.ListAccessTokens(ctx)
|
||||
}
|
||||
|
||||
// CreateAccessToken creates an access token
|
||||
|
|
@ -144,104 +77,7 @@ func CreateAccessToken(ctx *context.APIContext) {
|
|||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
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,
|
||||
})
|
||||
utils.CreateAccessToken(ctx)
|
||||
}
|
||||
|
||||
// DeleteAccessToken deletes an access token
|
||||
|
|
@ -272,45 +108,7 @@ func DeleteAccessToken(ctx *context.APIContext) {
|
|||
// "422":
|
||||
// "$ref": "#/responses/error"
|
||||
|
||||
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)
|
||||
utils.DeleteAccessToken(ctx)
|
||||
}
|
||||
|
||||
// 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": {
|
||||
"get": {
|
||||
"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