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:
17
README.md
17
README.md
@@ -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
176
classes/api_token_scopes.go
Normal 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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
166
member_token_scopes_smoke_test.go
Normal file
166
member_token_scopes_smoke_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
29
plugin.go
29
plugin.go
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user