Ctrl K
ComunidadGuías…

service/access_token_scope.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}