Files
summercms/modules/cabana/token_scopes.go
Jakub Zych ffdb5d00ca feat(16.3-01): account token API with OpenAPI and phrase keys
- Add list/create/revoke/scopes handlers, validation, and soft revoke
- Regenerate admin OpenAPI and TS types; inventory and conformance cases
- Add server-side api_tokens phrase keys (en/pl)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:51:11 +02:00

396 lines
10 KiB
Go

package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
const (
scopeAll = "*"
scopeAdminRead = "admin:read"
)
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
var allowedTokenMethods = map[string]bool{
"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
type compiledTokenScope struct {
Name string
Label string
Description string
Permissions []string
Methods map[string]bool
Full bool // framework scopes * and admin:read
PluginID string
}
type tokenScopeCatalog struct {
ordered []*compiledTokenScope
byName map[string]*compiledTokenScope
}
const (
matchNone = iota
matchPartial
matchFull
)
func compileTokenScopes(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
cat := &tokenScopeCatalog{
byName: map[string]*compiledTokenScope{},
}
// Framework scopes first.
allMethods := map[string]bool{}
for m := range allowedTokenMethods {
allMethods[m] = true
}
cat.ordered = append(cat.ordered, &compiledTokenScope{
Name: scopeAll,
Label: "backend::lang.api_tokens.scope_all",
Description: "backend::lang.api_tokens.scope_all_description",
Methods: allMethods,
Full: true,
})
cat.byName[scopeAll] = cat.ordered[0]
cat.ordered = append(cat.ordered, &compiledTokenScope{
Name: scopeAdminRead,
Label: "backend::lang.api_tokens.scope_admin_read",
Description: "backend::lang.api_tokens.scope_admin_read_description",
Methods: map[string]bool{"GET": true, "HEAD": true},
Full: true,
})
cat.byName[scopeAdminRead] = cat.ordered[1]
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasTokenScopes)
if !ok || src == nil {
continue
}
for _, scope := range src.TokenScopes() {
if err := validatePluginTokenScope(reg, plugin.ID(), scope, cat); err != nil {
return err
}
methods := map[string]bool{}
for _, m := range scope.Methods {
methods[m] = true
}
perms := append([]string(nil), scope.Permissions...)
compiled := &compiledTokenScope{
Name: scope.Name,
Label: scope.Label,
Description: scope.Description,
Permissions: perms,
Methods: methods,
PluginID: plugin.ID(),
}
cat.ordered = append(cat.ordered, compiled)
cat.byName[scope.Name] = compiled
}
}
reg.tokenScopes = cat
return nil
}
func validatePluginTokenScope(reg *Registry, pluginID string, scope pact.TokenScope, cat *tokenScopeCatalog) error {
name := strings.TrimSpace(scope.Name)
if name == "" || !tokenScopeNameRE.MatchString(name) {
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
}
if name == scopeAll || name == "admin" || strings.HasPrefix(name, "admin:") {
return fmt.Errorf("cabana: plugin %s registered reserved token scope name %q", pluginID, name)
}
if existing, ok := cat.byName[name]; ok {
other := existing.PluginID
if other == "" {
other = "framework"
}
return fmt.Errorf("cabana: duplicate token scope %q registered by %s and %s", name, other, pluginID)
}
if strings.TrimSpace(scope.Label) == "" {
return fmt.Errorf("cabana: plugin %s token scope %s has an empty label", pluginID, name)
}
if strings.TrimSpace(scope.Description) == "" {
return fmt.Errorf("cabana: plugin %s token scope %s has an empty description", pluginID, name)
}
if len(scope.Permissions) == 0 {
return fmt.Errorf("cabana: plugin %s token scope %s has empty permissions", pluginID, name)
}
if len(scope.Methods) == 0 {
return fmt.Errorf("cabana: plugin %s token scope %s has empty methods", pluginID, name)
}
hasGET := false
seen := map[string]bool{}
for _, m := range scope.Methods {
if m != strings.ToUpper(m) || !allowedTokenMethods[m] {
return fmt.Errorf("cabana: plugin %s token scope %s has invalid method %q", pluginID, name, m)
}
if seen[m] {
continue
}
seen[m] = true
if m == "GET" {
hasGET = true
}
}
if !hasGET {
return fmt.Errorf("cabana: plugin %s token scope %s methods must include GET", pluginID, name)
}
for _, code := range scope.Permissions {
if !permissionCode(code, true) || !reg.permissionExists(code) {
return fmt.Errorf("cabana: plugin %s token scope %s names unknown permission %q", pluginID, name, code)
}
}
return nil
}
func validateTokenScopeMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
if reg == nil || reg.tokenScopes == nil || tr == nil {
return nil
}
for _, scope := range reg.tokenScopes.ordered {
who := scope.PluginID
if who == "" {
who = "framework"
}
for _, text := range []string{scope.Label, scope.Description} {
if strings.Contains(text, "::") && !tr.Has(text) {
return fmt.Errorf("cabana: token scope %s (%s) names missing phrase key %s", scope.Name, who, text)
}
}
}
return nil
}
// scopeMatch reports how pattern covers required (plan match table).
func scopeMatch(pattern, required string) int {
pattern = strings.TrimSpace(pattern)
required = strings.TrimSpace(required)
if pattern == "" || required == "" {
return matchNone
}
patLeading := len(pattern) > 1 && strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, ".*")
reqLeading := len(required) > 1 && strings.HasPrefix(required, "*") && !strings.HasSuffix(required, ".*")
patSuffixWild := strings.HasSuffix(pattern, ".*")
reqSuffixWild := strings.HasSuffix(required, ".*")
// Leading *.suffix: exact required only → partial via the pattern; any
// wildcard required or pattern → none (conservative).
if patLeading {
if reqSuffixWild || reqLeading {
return matchNone
}
suffix := strings.TrimPrefix(pattern, "*")
if strings.HasSuffix(required, suffix) && required != suffix {
return matchPartial
}
return matchNone
}
if reqLeading {
return matchNone
}
switch {
case !patSuffixWild && !reqSuffixWild:
if pattern == required {
return matchFull
}
return matchNone
case patSuffixWild && !reqSuffixWild:
patPrefix := strings.TrimSuffix(pattern, "*")
if patPrefix != required && strings.HasPrefix(required, patPrefix) {
return matchFull
}
return matchNone
case !patSuffixWild && reqSuffixWild:
reqPrefix := strings.TrimSuffix(required, "*")
if reqPrefix != pattern && strings.HasPrefix(pattern, reqPrefix) {
return matchPartial
}
return matchNone
default:
patPrefix := strings.TrimSuffix(pattern, "*")
reqPrefix := strings.TrimSuffix(required, "*")
if patPrefix == reqPrefix || strings.HasPrefix(reqPrefix, patPrefix) {
return matchFull
}
if strings.HasPrefix(patPrefix, reqPrefix) {
return matchPartial
}
return matchNone
}
}
func ownerHas(p *bouncer.Principal, code string) bool {
if p == nil {
return false
}
return p.IsSuperuser || granted(p.PermissionGrants, code)
}
// allowsUnderScope implements D-03 literal reading: some method-allowed scope
// matches every required code, and some required code is owner-held inside it.
func allowsUnderScope(p *bouncer.Principal, required []string) bool {
if p == nil || p.Scope == nil || p.Scope.Full {
return false
}
if len(required) == 0 {
return false
}
for _, patterns := range p.Scope.Scopes {
if scopeSliceAllows(p, patterns, required) {
return true
}
}
return false
}
func scopeSliceAllows(p *bouncer.Principal, patterns, required []string) bool {
type hit struct {
kind int
pattern string
}
hits := make([]hit, len(required))
for i, code := range required {
best := matchNone
bestPat := ""
for _, pattern := range patterns {
kind := scopeMatch(pattern, code)
if kind > best {
best = kind
bestPat = pattern
}
}
if best == matchNone {
return false
}
hits[i] = hit{kind: best, pattern: bestPat}
}
for i, code := range required {
h := hits[i]
switch h.kind {
case matchFull:
if ownerHas(p, code) {
return true
}
case matchPartial:
if ownerHas(p, h.pattern) {
return true
}
}
}
return false
}
// scopeCoversAll reports whether some scope slice matches every cover code
// (full or partial). Used by ResolveBackend (Task 2).
func scopeCoversAll(limit *bouncer.ScopeLimit, cover []string) bool {
if limit == nil || limit.Full {
return true
}
if len(cover) == 0 {
return false
}
for _, patterns := range limit.Scopes {
ok := true
for _, code := range cover {
matched := false
for _, pattern := range patterns {
if scopeMatch(pattern, code) != matchNone {
matched = true
break
}
}
if !matched {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
// scopeOverlaps is the D-17 picker/issue rule: a plugin scope is offered when
// some registered permission code has a full match in the scope and the owner
// holds that code. "*" is always offered. "admin:read" is offered when the
// owner is a superuser or holds at least one catalog permission.
func scopeOverlaps(reg *Registry, owner *bouncer.Principal, name string) bool {
if reg == nil || reg.tokenScopes == nil || owner == nil {
return false
}
if name == scopeAll {
return true
}
scope, ok := reg.tokenScopes.byName[name]
if !ok {
return false
}
if scope.Full {
if owner.IsSuperuser {
return true
}
for code := range reg.permissions {
if ownerHas(owner, code) {
return true
}
}
return false
}
for code := range reg.permissions {
for _, pattern := range scope.Permissions {
if scopeMatch(pattern, code) == matchFull && ownerHas(owner, code) {
return true
}
}
}
return false
}
func methodFilterScopes(cat *tokenScopeCatalog, stored []string, method string) (limit *bouncer.ScopeLimit, known int, methodOK int) {
if cat == nil {
return nil, 0, 0
}
method = strings.ToUpper(method)
var scopes [][]string
full := false
for _, name := range stored {
scope, ok := cat.byName[name]
if !ok {
continue
}
known++
if !scope.Methods[method] {
continue
}
methodOK++
if scope.Full {
full = true
continue
}
scopes = append(scopes, append([]string(nil), scope.Permissions...))
}
if known == 0 {
return nil, 0, 0
}
if methodOK == 0 {
return nil, known, 0
}
return &bouncer.ScopeLimit{Full: full, Scopes: scopes}, known, methodOK
}