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.
396 lines
10 KiB
Go
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
|
|
}
|