Files
sm-user-plugin/classes/api_token_scopes.go
Jakub Zych ee5c096d0d feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)
- CompileAPITokenScopes builds the catalog from the built-ins and the
  activated plugins' declarations; a bad name, a redeclared built-in, a
  duplicate, an empty label or a missing phrase key fails boot
- POST /tokens, user:token:create and MintAPITokenFor check the
  application's own catalog (422 text unchanged); MintAPIToken keeps its
  signature with a process-wide fallback for declared scopes
- GET /_user/api/v1/tokens/scopes lists the built-ins and the declared
  scopes with translated labels
- built-in scope labels in en and pl; TestMemberTokenScopesSmoke
2026-10-23 17:00:12 +02:00

177 lines
6.0 KiB
Go

package classes
import (
"fmt"
"slices"
"strings"
"sync"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// APITokenScope is one scope a member personal API token (usr_) may carry:
// a built-in (read, write, ai) or one a plugin declared through
// pact.HasFrontendTokenScopes. Label and Description are phrase keys
// (containing "::") or literal text; PluginID is the declaring plugin, empty
// for a built-in.
type APITokenScope struct {
Name string
Label string
Description string
PluginID string
BuiltIn bool
}
// APITokenScopeCatalog is the compiled list of member token scopes of one
// application: the built-ins first, then the declared scopes in activation
// order. Boot publishes it; APITokenScopesFor reads it.
type APITokenScopeCatalog struct {
scopes []APITokenScope
byName map[string]int
}
// Scopes returns a copy of the catalog, built-ins first.
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
if c == nil {
return builtInAPITokenScopes()
}
return slices.Clone(c.scopes)
}
// Allowed reports whether a member token may carry name in this catalog.
func (c *APITokenScopeCatalog) Allowed(name string) bool {
if c == nil {
return slices.Contains(AllowedAPITokenScopes, name)
}
_, ok := c.byName[name]
return ok
}
// builtInAPITokenScopeKeys are the label and description phrase keys of
// the built-in scopes, in AllowedAPITokenScopes order.
var builtInAPITokenScopeKeys = map[string][2]string{
"read": {"golem15.user::lang.api_tokens.scope_read", "golem15.user::lang.api_tokens.scope_read_description"},
"write": {"golem15.user::lang.api_tokens.scope_write", "golem15.user::lang.api_tokens.scope_write_description"},
"ai": {"golem15.user::lang.api_tokens.scope_ai", "golem15.user::lang.api_tokens.scope_ai_description"},
}
// builtInAPITokenScopes are AllowedAPITokenScopes with their phrase keys.
func builtInAPITokenScopes() []APITokenScope {
out := make([]APITokenScope, 0, len(AllowedAPITokenScopes))
for _, name := range AllowedAPITokenScopes {
keys := builtInAPITokenScopeKeys[name]
out = append(out, APITokenScope{Name: name, Label: keys[0], Description: keys[1], BuiltIn: true})
}
return out
}
func newAPITokenScopeCatalog(scopes []APITokenScope) *APITokenScopeCatalog {
c := &APITokenScopeCatalog{scopes: scopes, byName: make(map[string]int, len(scopes))}
for i, scope := range scopes {
c.byName[scope.Name] = i
}
return c
}
// CompileAPITokenScopes builds the member token scope catalog from the
// built-ins and every plugin that implements pact.HasFrontendTokenScopes, in
// the order given (party.Activated). A declared name is trimmed and must
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai)
// and must not repeat a name another plugin (or the same one) declared; its
// label and description must not be empty. Any violation is an error, which
// fails boot. Phrase keys are checked against the translator by Boot. Every
// name of a catalog that compiles is added to the process-wide set the
// legacy MintAPIToken accepts.
func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error) {
scopes := builtInAPITokenScopes()
owner := map[string]string{}
for _, scope := range scopes {
owner[scope.Name] = ""
}
var declared []string
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasFrontendTokenScopes)
if !ok || src == nil {
continue
}
id := plugin.ID()
for _, scope := range src.FrontendTokenScopes() {
name := strings.TrimSpace(scope.Name)
if name == "" || !pact.ValidTokenScopeName(name) {
return nil, fmt.Errorf("golem15.user: plugin %s declared invalid member token scope name %q", id, scope.Name)
}
if other, taken := owner[name]; taken {
if other == "" {
return nil, fmt.Errorf("golem15.user: plugin %s declared the built-in member token scope %q", id, name)
}
return nil, fmt.Errorf("golem15.user: duplicate member token scope %q declared by %s and %s", name, other, id)
}
if strings.TrimSpace(scope.Label) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty label", id, name)
}
if strings.TrimSpace(scope.Description) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty description", id, name)
}
owner[name] = id
scopes = append(scopes, APITokenScope{Name: name, Label: scope.Label, Description: scope.Description, PluginID: id})
declared = append(declared, name)
}
}
declaredScopes.add(declared)
return newAPITokenScopeCatalog(scopes), nil
}
// APITokenScopesFor returns the catalog Boot published on app, or the
// built-ins alone when none is published.
func APITokenScopesFor(app *backpack.App) *APITokenScopeCatalog {
if app != nil {
if catalog, ok := app.Lookup[*APITokenScopeCatalog](); ok && catalog != nil {
return catalog
}
}
return newAPITokenScopeCatalog(builtInAPITokenScopes())
}
// declaredScopes is the process-wide set of every member token scope name a
// compiled catalog declared, the legacy MintAPIToken's fallback. Production
// runs one application per process, so it equals that application's
// declared scopes.
var declaredScopes = &scopeSet{names: map[string]bool{}}
type scopeSet struct {
mu sync.RWMutex
names map[string]bool
}
func (s *scopeSet) add(names []string) {
if len(names) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, name := range names {
s.names[name] = true
}
}
func (s *scopeSet) has(name string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.names[name]
}
// ResetDeclaredAPITokenScopes clears the process-wide set of declared member
// token scopes that the legacy MintAPIToken accepts. It is a test seam for
// tests outside this package that compile catalogs; production never calls
// it.
func ResetDeclaredAPITokenScopes() {
declaredScopes.mu.Lock()
defer declaredScopes.mu.Unlock()
declaredScopes.names = map[string]bool{}
}