Files
summercms/modules/cabana/token_scopes.go
Jakub Zych a5ce43f211 fix(19.7): RB-WR-02 a padded token scope name fails boot
validatePluginTokenScope checked the trimmed name with
pact.ValidTokenScopeName while compileTokenScopes registered the
untrimmed one, so " blog:read" booted, was listed under its padded name
and could never be granted, and the duplicate check depended on
registration order. The name is now validated exactly as registered,
which is the shared rule pact.ValidTokenScopeName states.
2026-10-23 20:29:30 +02:00

396 lines
10 KiB
Go

package cabana
import (
"fmt"
"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 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 {
// The name is checked exactly as compileTokenScopes registers it: a padded
// name (" blog:read", "blog:read\n") fails pact.ValidTokenScopeName and
// boot, instead of being listed under a name no token request can match.
name := scope.Name
if !pact.ValidTokenScopeName(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
}