- 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
177 lines
6.0 KiB
Go
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{}
|
|
}
|