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 }