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
This commit is contained in:
176
classes/api_token_scopes.go
Normal file
176
classes/api_token_scopes.go
Normal file
@@ -0,0 +1,176 @@
|
||||
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{}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import (
|
||||
|
||||
const DefaultAPITokenPrefix = "usr_"
|
||||
|
||||
// DefaultAPITokenScopes is what a token minted without scopes carries, and
|
||||
// AllowedAPITokenScopes are the built-in member token scopes. Plugins add
|
||||
// their own through pact.HasFrontendTokenScopes (APITokenScopeCatalog).
|
||||
var (
|
||||
DefaultAPITokenScopes = []string{"read"}
|
||||
AllowedAPITokenScopes = []string{"read", "write", "ai"}
|
||||
@@ -44,8 +47,27 @@ func APITokenPrefix(app *backpack.App) (string, error) {
|
||||
}
|
||||
|
||||
// MintAPIToken builds a token record and returns its one-time clear-text
|
||||
// secret. A nil scopes slice receives DefaultAPITokenScopes.
|
||||
// secret. A nil scopes slice receives DefaultAPITokenScopes. It accepts the
|
||||
// built-in scopes (AllowedAPITokenScopes) and, as a fallback for existing
|
||||
// callers, every member token scope a catalog compiled in this process
|
||||
// declared (CompileAPITokenScopes). New code calls MintAPITokenFor, which
|
||||
// checks the scopes against the application's own catalog.
|
||||
func MintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) {
|
||||
return mintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs, func(scope string) bool {
|
||||
return slices.Contains(AllowedAPITokenScopes, scope) || declaredScopes.has(scope)
|
||||
})
|
||||
}
|
||||
|
||||
// MintAPITokenFor is MintAPIToken with the scopes checked against app's
|
||||
// member token scope catalog (APITokenScopesFor): the built-ins and the
|
||||
// scopes app's activated plugins declared through
|
||||
// pact.HasFrontendTokenScopes. A scope another application in the same
|
||||
// process declared is refused.
|
||||
func MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) {
|
||||
return mintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs, APITokenScopesFor(app).Allowed)
|
||||
}
|
||||
|
||||
func mintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint, allowed func(string) bool) (string, *models.APIToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if userID == 0 || name == "" || len(name) > 255 {
|
||||
return "", nil, fmt.Errorf("a user and token name of 1 to 255 characters are required")
|
||||
@@ -59,7 +81,7 @@ func MintAPIToken(prefix string, userID uint, name string, scopes []string, expi
|
||||
scopes = slices.Clone(scopes)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if !slices.Contains(AllowedAPITokenScopes, scope) {
|
||||
if !allowed(scope) {
|
||||
return "", nil, fmt.Errorf("scope %q is not allowed", scope)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user