Files
new-api/service/access_token_scope.go
CaIon 4924361aef feat(auth): edit the name and permissions of an access token
The token menu now opens an edit dialog instead of a rename prompt.
Renaming still needs no verification. Changing the permissions requires
step-up verification bound to the token id and the new grant, so a
proof cannot be replayed for another token or another set of
permissions; the grant is checked against what the owner can do, the
same as at creation. PATCH /api/user/access_tokens/:id takes an
optional scopes list and is rate limited like the other token routes.

A token that holds permissions the catalog no longer offers can still
be renamed and keeps them; a new grant only contains offered
permissions. Edit stays disabled until the permission catalog has
loaded.

Grant changes are audited as access_token.update with the previous and
new scopes, and audit details show both as permission names.

The permission picker for tokens and admin users now uses toggle
buttons. Token permissions are grouped by personal, administration and
system, each group collapsible with a selected count and select-all or
clear.
2026-09-30 20:52:36 +08:00

274 lines
12 KiB
Go

package service
import (
"errors"
"slices"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service/authz"
)
const (
AccessTokenGroupPersonal = "personal"
AccessTokenGroupAdmin = "admin"
AccessTokenGroupSystem = "system"
AccessTokenMaxPerUser = 20
AccessTokenDefaultExpiryDays = 30
accessTokenActionReveal = "reveal"
accessTokenScopeSeparator = ":"
)
var (
ErrAccessTokenScopeInvalid = errors.New("access token scope is invalid")
ErrAccessTokenScopeForbidden = errors.New("access token scope cannot be granted")
)
// AccessTokenResource mirrors authz.ResourceDefinition so both catalogs share
// one JSON shape and one frontend matrix component.
type AccessTokenResource struct {
Resource string `json:"resource"`
LabelKey string `json:"label_key"`
Actions []authz.ActionDefinition `json:"actions"`
group string
minRole int
}
type AccessTokenCatalogGroup struct {
Group string `json:"group"`
Resources []AccessTokenResource `json:"resources"`
}
func accessTokenView(description string) authz.ActionDefinition {
return authz.ActionDefinition{Action: authz.ActionRead, LabelKey: "View", DescriptionKey: description}
}
func accessTokenEdit(description string) authz.ActionDefinition {
return authz.ActionDefinition{Action: authz.ActionWrite, LabelKey: "Edit", DescriptionKey: description}
}
// accessTokenStaticResources covers dashboard routes that are not guarded by a
// Casbin permission. Casbin resources come from authz.Catalog() at runtime and
// must never be duplicated here.
var accessTokenStaticResources = []AccessTokenResource{
{Resource: "profile", LabelKey: "Profile", group: AccessTokenGroupPersonal, minRole: common.RoleCommonUser, Actions: []authz.ActionDefinition{
accessTokenView("View your profile, groups, and available models."),
accessTokenEdit("Change your display name and personal settings."),
}},
{Resource: "api_key", LabelKey: "API keys", group: AccessTokenGroupPersonal, minRole: common.RoleCommonUser, Actions: []authz.ActionDefinition{
accessTokenView("View API keys and their settings without the full key."),
accessTokenEdit("Create, edit, and delete API keys."),
{Action: accessTokenActionReveal, LabelKey: "Reveal full keys", DescriptionKey: "View complete API keys."},
}},
{Resource: "usage", LabelKey: "Usage", group: AccessTokenGroupPersonal, minRole: common.RoleCommonUser, Actions: []authz.ActionDefinition{
accessTokenView("View your usage logs, statistics, tasks, and audit logs."),
}},
{Resource: "wallet", LabelKey: "Wallet", group: AccessTokenGroupPersonal, minRole: common.RoleCommonUser, Actions: []authz.ActionDefinition{
accessTokenView("View balance, top-up records, subscriptions, and referral information."),
accessTokenEdit("Top up, pay for subscriptions, check in, and transfer referral rewards."),
}},
{Resource: "account_security", LabelKey: "Account security", group: AccessTokenGroupPersonal, minRole: common.RoleCommonUser, Actions: []authz.ActionDefinition{
accessTokenView("View Passkey, two-factor, and sign-in binding status."),
accessTokenEdit("Includes changes that require security verification, such as password, two-factor settings, and account deletion."),
}},
{Resource: "user", LabelKey: "Users", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View users, their sign-in bindings, and permission settings."),
accessTokenEdit("Create, edit, disable, and delete users, and reset their security settings."),
}},
{Resource: "billing", LabelKey: "Billing management", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View all top-up records, subscription plans, and user subscriptions."),
accessTokenEdit("Complete top-up orders and manage subscription plans and user subscriptions."),
}},
{Resource: "model", LabelKey: "Models", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View models and vendors, and preview upstream sync."),
accessTokenEdit("Edit models and vendors, and sync them from upstream."),
}},
{Resource: "deployment", LabelKey: "Deployments", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View deployments and price estimates."),
accessTokenEdit("Create, edit, and delete deployments, and test connections."),
}},
{Resource: "log", LabelKey: "Logs", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View all users' usage logs, statistics, and tasks."),
}},
{Resource: "redemption", LabelKey: "Redemption codes", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View redemption codes."),
accessTokenEdit("Create, edit, and delete redemption codes."),
}},
{Resource: "group", LabelKey: "Groups", group: AccessTokenGroupAdmin, minRole: common.RoleAdminUser, Actions: []authz.ActionDefinition{
accessTokenView("View groups and prefill groups."),
accessTokenEdit("Create, edit, and delete prefill groups."),
}},
{Resource: "option", LabelKey: "System settings", group: AccessTokenGroupSystem, minRole: common.RoleRootUser, Actions: []authz.ActionDefinition{
accessTokenView("View system settings, custom OAuth providers, and price sync sources."),
accessTokenEdit("Change system settings and custom OAuth providers, and sync prices."),
}},
{Resource: "plugin", LabelKey: "Task plugins", group: AccessTokenGroupSystem, minRole: common.RoleRootUser, Actions: []authz.ActionDefinition{
accessTokenView("View installed task plugins and their settings."),
accessTokenEdit("Install, configure, test, and remove task plugins."),
}},
{Resource: "ops", LabelKey: "Operations", group: AccessTokenGroupSystem, minRole: common.RoleRootUser, Actions: []authz.ActionDefinition{
accessTokenView("View performance, system tasks, and system information."),
accessTokenEdit("Run and manage maintenance tasks and performance settings."),
}},
}
// AccessTokenScopeOf is the token scope key for a Casbin permission.
func AccessTokenScopeOf(permission authz.Permission) string {
return permission.Resource + accessTokenScopeSeparator + permission.Action
}
// AccessTokenCatalog lists the scopes this user may grant right now, grouped
// for display. Resources without a grantable action and empty groups are
// omitted.
func AccessTokenCatalog(userID, role int) []AccessTokenCatalogGroup {
resources := map[string][]AccessTokenResource{}
for _, resource := range accessTokenStaticResources {
if role < resource.minRole {
continue
}
resource.Actions = slices.Clone(resource.Actions)
resources[resource.group] = append(resources[resource.group], resource)
}
if role >= common.RoleAdminUser {
for _, definition := range authz.Catalog() {
resource := AccessTokenResource{Resource: definition.Resource, LabelKey: definition.LabelKey}
for _, action := range definition.Actions {
if authz.Can(userID, role, authz.Permission{Resource: definition.Resource, Action: action.Action}) {
resource.Actions = append(resource.Actions, action)
}
}
if len(resource.Actions) > 0 {
resources[AccessTokenGroupAdmin] = append(resources[AccessTokenGroupAdmin], resource)
}
}
}
groups := make([]AccessTokenCatalogGroup, 0, 3)
for _, group := range []string{AccessTokenGroupPersonal, AccessTokenGroupAdmin, AccessTokenGroupSystem} {
if len(resources[group]) > 0 {
groups = append(groups, AccessTokenCatalogGroup{Group: group, Resources: resources[group]})
}
}
return groups
}
// AccessTokenScopeDictionary lists every resource a token scope can name,
// whatever the viewer may grant, so stored grants keep their labels after the
// granting user loses a permission or when another user reads them.
func AccessTokenScopeDictionary() []AccessTokenResource {
definitions := authz.Catalog()
resources := make([]AccessTokenResource, 0, len(accessTokenStaticResources)+len(definitions))
for _, resource := range accessTokenStaticResources {
resource.Actions = slices.Clone(resource.Actions)
resources = append(resources, resource)
}
for _, definition := range definitions {
resources = append(resources, AccessTokenResource{Resource: definition.Resource, LabelKey: definition.LabelKey, Actions: definition.Actions})
}
return resources
}
// NormalizeAccessTokenScopes validates a requested grant against the catalog
// the user sees and returns it trimmed, de-duplicated and sorted.
func NormalizeAccessTokenScopes(userID, role int, scopes []string) ([]string, error) {
normalized, ok := NormalizeAccessTokenScopeList(scopes)
if !ok {
return nil, ErrAccessTokenScopeInvalid
}
grantable := make(map[string]bool)
for _, group := range AccessTokenCatalog(userID, role) {
for _, resource := range group.Resources {
for _, action := range resource.Actions {
grantable[resource.Resource+accessTokenScopeSeparator+action.Action] = true
}
}
}
for _, scope := range normalized {
if grantable[scope] {
continue
}
if !knownAccessTokenScope(scope) {
return nil, ErrAccessTokenScopeInvalid
}
return nil, ErrAccessTokenScopeForbidden
}
return normalized, nil
}
func knownAccessTokenScope(scope string) bool {
resourceName, action, ok := strings.Cut(scope, accessTokenScopeSeparator)
if !ok {
return false
}
for _, resource := range accessTokenStaticResources {
if resource.Resource == resourceName && slices.ContainsFunc(resource.Actions, func(definition authz.ActionDefinition) bool { return definition.Action == action }) {
return true
}
}
return slices.Contains(authz.AllPermissions(), authz.Permission{Resource: resourceName, Action: action})
}
func AccessTokenScopeGranted(scopes []string, scope string) bool {
return scope != "" && slices.Contains(scopes, scope)
}
// accessTokenVerificationScopes maps every verification scope to the token
// scope a PAT needs to obtain its proof. An empty value means PATs can never
// obtain it; scopes missing from this table are denied too.
var accessTokenVerificationScopes = map[string]string{
VerificationScopeChannelKeyRead: AccessTokenScopeOf(authz.ChannelSecretView),
VerificationScopeAdminUserCreate: "user:write",
VerificationScopeAdminUserUpdate: "user:write",
VerificationScopeAdminUserDelete: "user:write",
VerificationScopeAdminUserManage: "user:write",
VerificationScopeAdminUserPasskeyReset: "user:write",
VerificationScopeAdminUserTwoFADisable: "user:write",
VerificationScopeAdminUserBindingClear: "user:write",
VerificationScopePasskeyRegister: "account_security:write",
VerificationScopePasskeyDelete: "account_security:write",
VerificationScopeTwoFASetup: "account_security:write",
VerificationScopeTwoFADisable: "account_security:write",
VerificationScopeTwoFABackupCodes: "account_security:write",
VerificationScopePasswordSet: "account_security:write",
VerificationScopePasswordChange: "account_security:write",
VerificationScopeAccountBind: "account_security:write",
VerificationScopeAccountUnbind: "account_security:write",
VerificationScopeAccountDelete: "account_security:write",
VerificationScopeAccessTokenGenerate: "",
VerificationScopeAccessTokenUpdate: "",
VerificationScopeAccessTokenRevoke: "",
VerificationScopeLogin: "",
}
// AccessTokenVerificationScope returns the token scope a PAT needs for a
// verification scope and whether the scope is listed at all.
func AccessTokenVerificationScope(verificationScope string) (scope string, listed bool) {
scope, listed = accessTokenVerificationScopes[verificationScope]
return scope, listed
}
// requireAccessTokenVerificationScope rejects a step-up request from a PAT
// whose grant does not cover the operation. Browser sessions pass through.
func requireAccessTokenVerificationScope(identity AuthIdentity, verificationScope string) error {
tokenID, ok := model.ParseAccessTokenSessionID(identity.SessionID)
if !ok {
return nil
}
required, _ := AccessTokenVerificationScope(verificationScope)
if required == "" {
return ErrVerificationForbidden
}
token, err := model.GetUserAccessToken(identity.UserID, tokenID)
if errors.Is(err, model.ErrAccessTokenNotFound) {
return ErrAuthTokenInvalid
}
if err != nil {
return err
}
if !AccessTokenScopeGranted(token.GetScopes(), required) {
return ErrVerificationForbidden
}
return nil
}