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{} }