Files
sm-user-plugin/classes/api_token_scopes.go
Jakub Zych 25fd981d7f feat(32.1-02): expand OAuth MCP scopes from the published catalog
- Publish the compiled member-token catalog as pact.MemberTokenScopeCatalog
- Imply declared names only when the MCP subject has a ClientID
- Reject a bad Implies and the reserved names backend and offline_access
2026-10-28 15:01:04 +01:00

210 lines
7.4 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. Implies is empty on a built-in. On a declared scope it is
// empty, read, or write: an OAuth MCP grant of that stock scope also
// satisfies the declared name. The name is not stored on the grant.
type APITokenScope struct {
Name string
Label string
Description string
PluginID string
BuiltIn bool
Implies string
}
// 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, and the same value as pact.MemberTokenScopeCatalog.
// APITokenScopesFor reads the concrete catalog.
type APITokenScopeCatalog struct {
scopes []APITokenScope
byName map[string]int
}
var _ pact.MemberTokenScopeCatalog = (*APITokenScopeCatalog)(nil)
// 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
}
// MemberTokenScopes returns the compiled list as pact scopes, built-ins
// first, then declared scopes. Implied names are not a separate entry: they
// are the Implies field of the declared scope.
func (c *APITokenScopeCatalog) MemberTokenScopes() []pact.FrontendTokenScope {
scopes := c.Scopes()
out := make([]pact.FrontendTokenScope, 0, len(scopes))
for _, scope := range scopes {
out = append(out, pact.FrontendTokenScope{
Name: scope.Name,
Label: scope.Label,
Description: scope.Description,
Implies: scope.Implies,
})
}
return out
}
// 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),
// must not be the reserved name backend or offline_access, and must not
// repeat a name another plugin (or the same one) declared; its label and
// description must not be empty, and Implies must be empty, read, or write.
// Built-ins keep Implies 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 name == "backend" || name == "offline_access" {
return nil, fmt.Errorf("golem15.user: plugin %s declared the reserved member token scope %q", id, name)
}
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)
}
if scope.Implies != "" && scope.Implies != "read" && scope.Implies != "write" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has implies %q", id, name, scope.Implies)
}
owner[name] = id
scopes = append(scopes, APITokenScope{
Name: name, Label: scope.Label, Description: scope.Description, PluginID: id, Implies: scope.Implies,
})
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{}
}