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

@@ -36,6 +36,18 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
- **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`:
```go
func (Plugin) FrontendTokenScopes() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{
{Name: "inventory:read", Label: "acme.inventory::lang.scopes.read", Description: "acme.inventory::lang.scopes.read_description"},
{Name: "inventory:write", Label: "acme.inventory::lang.scopes.write", Description: "acme.inventory::lang.scopes.write_description"},
}
}
```
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a name two plugins declare (the error names both), an empty label or description, or a label or description phrase key (containing `::`) that the translator does not have. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
@@ -110,6 +122,7 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
| `POST /avatar/remove` | `controllers.RemoveAvatar` |
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
| `GET /tokens/scopes` | `controllers.APITokenScopes` (`jwt.auth`) |
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` (deprecated: the code is in the logged path; use the POST route) |
| `POST /backend-link/show` | `controllers.BackendLinkShowPost` |
@@ -205,7 +218,7 @@ Packages:
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
@@ -309,6 +322,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.
`TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAuthTimeSmoke` checks the `auth_time` proof: a session signed in before the request and refreshed after it gets `stale_session`, a sign-in after the request links, `POST /backend-link/show` answers for the code in the body, and My account's URL carries the code in the fragment. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. All five need Docker.
`TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAuthTimeSmoke` checks the `auth_time` proof: a session signed in before the request and refreshed after it gets `stale_session`, a sign-in after the request links, `POST /backend-link/show` answers for the code in the body, and My account's URL carries the code in the fragment. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. `TestMemberTokenScopesSmoke` checks the member token scope catalog (duplicate, built-in and bad names fail) and, with Docker, minting, the scope check, the list, `GET /tokens/scopes` and a second application refusing the scope. The others need Docker.
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.

176
classes/api_token_scopes.go Normal file
View File

@@ -0,0 +1,176 @@
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{}
}

View File

@@ -18,6 +18,9 @@ import (
const DefaultAPITokenPrefix = "usr_"
// DefaultAPITokenScopes is what a token minted without scopes carries, and
// AllowedAPITokenScopes are the built-in member token scopes. Plugins add
// their own through pact.HasFrontendTokenScopes (APITokenScopeCatalog).
var (
DefaultAPITokenScopes = []string{"read"}
AllowedAPITokenScopes = []string{"read", "write", "ai"}
@@ -44,8 +47,27 @@ func APITokenPrefix(app *backpack.App) (string, error) {
}
// MintAPIToken builds a token record and returns its one-time clear-text
// secret. A nil scopes slice receives DefaultAPITokenScopes.
// secret. A nil scopes slice receives DefaultAPITokenScopes. It accepts the
// built-in scopes (AllowedAPITokenScopes) and, as a fallback for existing
// callers, every member token scope a catalog compiled in this process
// declared (CompileAPITokenScopes). New code calls MintAPITokenFor, which
// checks the scopes against the application's own catalog.
func MintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) {
return mintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs, func(scope string) bool {
return slices.Contains(AllowedAPITokenScopes, scope) || declaredScopes.has(scope)
})
}
// MintAPITokenFor is MintAPIToken with the scopes checked against app's
// member token scope catalog (APITokenScopesFor): the built-ins and the
// scopes app's activated plugins declared through
// pact.HasFrontendTokenScopes. A scope another application in the same
// process declared is refused.
func MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) {
return mintAPIToken(prefix, userID, name, scopes, expiresAt, collectionIDs, APITokenScopesFor(app).Allowed)
}
func mintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint, allowed func(string) bool) (string, *models.APIToken, error) {
name = strings.TrimSpace(name)
if userID == 0 || name == "" || len(name) > 255 {
return "", nil, fmt.Errorf("a user and token name of 1 to 255 characters are required")
@@ -59,7 +81,7 @@ func MintAPIToken(prefix string, userID uint, name string, scopes []string, expi
scopes = slices.Clone(scopes)
}
for _, scope := range scopes {
if !slices.Contains(AllowedAPITokenScopes, scope) {
if !allowed(scope) {
return "", nil, fmt.Errorf("scope %q is not allowed", scope)
}
}

View File

@@ -79,7 +79,7 @@ Revoke a token with user:token:revoke and list them with user:token:list.`,
if err != nil {
return err
}
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 {
return fmt.Errorf("user:token:create: %w", err)
}

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)

View File

@@ -136,3 +136,10 @@ backend_link:
remove_success: The frontend account is unlinked.
unknown_user: No active frontend user has this email.
already_linked: This administrator or this frontend user is already linked.
api_tokens:
scope_read: Read
scope_read_description: Read your account and the data the site lets you see.
scope_write: Write
scope_write_description: Change your account and the data the site lets you edit.
scope_ai: AI
scope_ai_description: Use the site's AI features on your behalf.

View File

@@ -136,3 +136,10 @@ backend_link:
remove_success: Konto na stronie zostało odłączone.
unknown_user: Żaden aktywny użytkownik strony nie ma tego emaila.
already_linked: Ten administrator albo ten użytkownik strony jest już połączony.
api_tokens:
scope_read: Odczyt
scope_read_description: Odczyt Twojego konta i danych, które strona pozwala Ci oglądać.
scope_write: Zapis
scope_write_description: Zmiany na Twoim koncie i w danych, które strona pozwala Ci edytować.
scope_ai: AI
scope_ai_description: Korzystanie w Twoim imieniu z funkcji AI strony.

View File

@@ -0,0 +1,166 @@
package user
import (
"encoding/json"
"net/http"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/wire"
)
// scopeDeclarer is a plugin value that only declares member token scopes,
// for CompileAPITokenScopes; it is never registered.
type scopeDeclarer struct {
id string
scopes []pact.FrontendTokenScope
}
func (d scopeDeclarer) ID() string { return d.id }
func (d scopeDeclarer) Requires() []string { return nil }
func (d scopeDeclarer) Register(*backpack.App) error { return nil }
func (d scopeDeclarer) Boot(*backpack.App) error { return nil }
func (d scopeDeclarer) FrontendTokenScopes() []pact.FrontendTokenScope { return d.scopes }
// acmeServers declares the member token scope servers:write and serves a
// route behind user.api_token and user.scope:servers:write. It is
// registered for the test binary and activated only by the tests that name
// it.
type acmeServers struct{}
func (acmeServers) ID() string { return "acme.servers" }
func (acmeServers) Requires() []string { return []string{"golem15.user"} }
func (acmeServers) Register(*backpack.App) error { return nil }
func (acmeServers) Boot(*backpack.App) error { return nil }
func (acmeServers) FrontendTokenScopes() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{{Name: "servers:write", Label: "Write servers", Description: "Report server components."}}
}
func (acmeServers) Routes(r pact.Router) error {
r.Get("/_servers/api/v1/ping", func(w http.ResponseWriter, _ *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}, "user.api_token", "user.scope:servers:write")
return nil
}
func init() { party.Register(acmeServers{}) }
// TestMemberTokenScopesSmoke (E-2): a duplicate, a redeclared built-in and a
// bad name fail the catalog; with a plugin declaring servers:write, POST
// /tokens mints it, a route behind user.scope:servers:write accepts it and
// refuses a read token, an undeclared scope answers 422, GET /tokens/scopes
// lists the built-ins and the declared scope with labels, and a second
// application without the plugin refuses the scope over HTTP and through
// MintAPITokenFor. The Postgres part skips under -short.
func TestMemberTokenScopesSmoke(t *testing.T) {
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
t.Run("catalog validation", func(t *testing.T) {
scope := func(name string) []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{{Name: name, Label: "Label", Description: "Description"}}
}
for name, plugins := range map[string][]party.Plugin{
"duplicate": {scopeDeclarer{"acme.a", scope("acme:dup")}, scopeDeclarer{"acme.b", scope("acme:dup")}},
"built-in": {scopeDeclarer{"acme.a", scope("write")}},
"bad name": {scopeDeclarer{"acme.a", scope("Bad Name")}},
"empty label": {scopeDeclarer{"acme.a", []pact.FrontendTokenScope{{Name: "acme:x", Description: "Description"}}}},
} {
if _, err := classes.CompileAPITokenScopes(plugins); err == nil {
t.Fatalf("%s: CompileAPITokenScopes succeeded", name)
}
}
catalog, err := classes.CompileAPITokenScopes([]party.Plugin{scopeDeclarer{"acme.a", scope("acme:ok")}})
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range catalog.Scopes() {
names = append(names, s.Name)
}
if !slices.Equal(names, []string{"read", "write", "ai", "acme:ok"}) || !catalog.Allowed("acme:ok") || catalog.Allowed("acme:dup") {
t.Fatalf("catalog = %v", names)
}
})
t.Run("mint, check, list and discovery", func(t *testing.T) {
env := newAdminEnvWith(t, []string{"acme.servers"})
user := env.seedUser(t, "member-scopes", nil)
session := freshUserToken(t, user)
mint := func(e *adminEnv, scopes ...string) *http.Response {
t.Helper()
rec := e.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "agent " + adminStamp(), "scopes": scopes})
return rec.Result()
}
secretOf := func(res *http.Response) string {
t.Helper()
var body struct {
Token string `json:"token"`
}
if res.StatusCode != http.StatusCreated || json.NewDecoder(res.Body).Decode(&body) != nil || body.Token == "" {
t.Fatalf("mint = %d", res.StatusCode)
}
return body.Token
}
agent := secretOf(mint(env, "servers:write"))
env.expect(t, http.StatusOK, http.MethodGet, "/_servers/api/v1/ping", agent, nil)
reader := secretOf(mint(env, "read"))
rec := env.expect(t, http.StatusForbidden, http.MethodGet, "/_servers/api/v1/ping", reader, nil)
if !strings.Contains(rec.Body.String(), "Missing required scope: servers:write") {
t.Fatalf("read token at the servers route = %s", rec.Body.String())
}
rec = env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "nope", "scopes": []string{"servers:nope"}})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
t.Fatalf("undeclared scope = %d %s", rec.Code, rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens", session, nil)
if !strings.Contains(rec.Body.String(), `"servers:write"`) {
t.Fatalf("token list = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens/scopes", session, nil)
var listed struct {
Data []struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil || len(listed.Data) != 4 {
t.Fatalf("scopes = %s (%v)", rec.Body.String(), err)
}
want := []struct {
name, label string
builtIn bool
}{{"read", "Read", true}, {"write", "Write", true}, {"ai", "AI", true}, {"servers:write", "Write servers", false}}
for i, w := range want {
got := listed.Data[i]
if got.Name != w.name || got.Label != w.label || got.BuiltIn != w.builtIn || got.Description == "" || strings.Contains(got.Description, "::") {
t.Fatalf("scope %d = %+v, want %+v", i, got, w)
}
}
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/tokens/scopes", "", nil)
// A second application without the declaring plugin refuses it.
other := newAdminEnv(t)
rec = other.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "other", "scopes": []string{"servers:write"}})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
t.Fatalf("second application = %d %s", rec.Code, rec.Body.String())
}
if _, _, err := classes.MintAPITokenFor(other.app, "", user.ID, "other", []string{"servers:write"}, nil, nil); err == nil {
t.Fatal("MintAPITokenFor accepted another application's scope")
}
if _, _, err := classes.MintAPITokenFor(env.app, "", user.ID, "declaring", []string{"servers:write"}, nil, nil); err != nil {
t.Fatalf("MintAPITokenFor in the declaring application: %v", err)
}
})
}

View File

@@ -96,10 +96,10 @@ func TestPasswordPublicRoutes(t *testing.T) {
t.Fatalf("unset: Routes error %v", err)
}
// 18 routes up to v0.1.9, plus GET /backend-link/{code}, POST
// /backend-link/show and POST /backend-link/confirm (the account link)
// and GET /token/me.
if len(base) != 22 {
t.Fatalf("unset: %d routes, want 22", len(base))
// /backend-link/show and POST /backend-link/confirm (the account link),
// GET /token/me and GET /tokens/scopes.
if len(base) != 23 {
t.Fatalf("unset: %d routes, want 23", len(base))
}
seen := 0
for _, route := range base {
@@ -111,8 +111,8 @@ func TestPasswordPublicRoutes(t *testing.T) {
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
}
off := withoutPasswordRoutes(base)
if len(off) != 19 {
t.Fatalf("base without the password routes has %d routes, want 19", len(off))
if len(off) != 20 {
t.Fatalf("base without the password routes has %d routes, want 20", len(off))
}
for _, c := range []struct {

View File

@@ -21,6 +21,7 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
@@ -110,12 +111,40 @@ func (p *Plugin) Boot(app *backpack.App) error {
if err := p.bootBackendLinks(app); err != nil {
return err
}
if err := p.bootAPITokenScopes(app); err != nil {
return err
}
if err := p.bootMCPGuard(app); err != nil {
return err
}
return classes.RegisterUserAuthorizer(app)
}
// bootAPITokenScopes compiles the member token scope catalog from the
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
// key it names exists, and publishes it. A bad declaration fails boot.
func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
activated, _ := app.Lookup[party.Activated]()
catalog, err := classes.CompileAPITokenScopes(activated)
if err != nil {
return err
}
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
for _, scope := range catalog.Scopes() {
for _, text := range []string{scope.Label, scope.Description} {
if strings.Contains(text, "::") && !tr.Has(text) {
who := scope.PluginID
if who == "" {
who = "golem15.user"
}
return fmt.Errorf("golem15.user: member token scope %s (%s) names missing phrase key %s", scope.Name, who, text)
}
}
}
}
return app.Publish(catalog)
}
// bootMCPGuard registers the "user" MCP guard provider in the application's
// *pact.MCPGuards (publishing the registry when no one did yet) and
// publishes the same value as pact.MCPPersonalTokenResolver for the

View File

@@ -40,6 +40,7 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))
g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth")
g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth")
g.Get("/tokens/scopes", controllers.APITokenScopes(p.app), "jwt.auth")
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
g.Where("id", "[0-9]+")
g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))