feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)

- CompileAPITokenScopes builds the catalog from the built-ins and the
  activated plugins' declarations; a bad name, a redeclared built-in, a
  duplicate, an empty label or a missing phrase key fails boot
- POST /tokens, user:token:create and MintAPITokenFor check the
  application's own catalog (422 text unchanged); MintAPIToken keeps its
  signature with a process-wide fallback for declared scopes
- GET /_user/api/v1/tokens/scopes lists the built-ins and the declared
  scopes with translated labels
- built-in scope labels in en and pl; TestMemberTokenScopesSmoke
This commit is contained in:
Jakub Zych
2026-10-23 17:00:12 +02:00
parent 970719ce75
commit ee5c096d0d
11 changed files with 470 additions and 16 deletions

View File

@@ -3,7 +3,6 @@ package controllers
import (
"errors"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -35,7 +34,8 @@ func APITokenStore(app *backpack.App) http.HandlerFunc {
writeValidation(w, map[string][]string{"name": {"The name field is required and may not be longer than 255 characters."}})
return
}
scopes, err := apiTokenScopes(fields["scopes"])
catalog := classes.APITokenScopesFor(app)
scopes, err := apiTokenScopes(catalog, fields["scopes"])
if err != nil {
writeValidation(w, map[string][]string{"scopes": {err.Error()}})
return
@@ -54,7 +54,7 @@ func APITokenStore(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil)
secret, token, err := classes.MintAPITokenFor(app, prefix, user.ID, name, scopes, expiresAt, nil)
if err != nil || db.WithContext(r.Context()).Create(token).Error != nil {
writeOpaque500(w)
return
@@ -63,6 +63,37 @@ func APITokenStore(app *backpack.App) http.HandlerFunc {
}
}
// APITokenScopes serves GET /_user/api/v1/tokens/scopes behind jwt.auth:
// the member token scopes the caller may mint, {"data":[{"name","label",
// "description","built_in"}]}, the built-ins first, then the scopes the
// application's plugins declared. Phrase-key labels and descriptions are
// translated for the request locale.
func APITokenScopes(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := bouncer.User(r.Context())
if !ok || user == nil || user.ID == 0 {
writeUnauthorized(w)
return
}
tr := appTranslator(app)
text := func(s string) string {
if tr == nil || !strings.Contains(s, "::") {
return s
}
return tr.Get(r.Context(), s, nil)
}
scopes := classes.APITokenScopesFor(app).Scopes()
data := make([]map[string]any, 0, len(scopes))
for _, scope := range scopes {
data = append(data, map[string]any{
"name": scope.Name, "label": text(scope.Label),
"description": text(scope.Description), "built_in": scope.BuiltIn,
})
}
writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)})
}
}
// APITokenIndex lists the caller's live personal tokens without hashes.
func APITokenIndex(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -131,7 +162,9 @@ func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*g
return db, user, true
}
func apiTokenScopes(raw any) ([]string, error) {
// apiTokenScopes reads the scopes field: nil, or an array of names the
// catalog allows.
func apiTokenScopes(catalog *classes.APITokenScopeCatalog, raw any) ([]string, error) {
if raw == nil {
return nil, nil
}
@@ -142,7 +175,7 @@ func apiTokenScopes(raw any) ([]string, error) {
out := make([]string, 0, len(values))
for _, rawScope := range values {
scope, ok := rawScope.(string)
if !ok || !slices.Contains(classes.AllowedAPITokenScopes, scope) {
if !ok || !catalog.Allowed(scope) {
return nil, errors.New("The selected scope is invalid.")
}
out = append(out, scope)