diff --git a/README.md b/README.md index 0177ca7..8e2f6be 100644 --- a/README.md +++ b/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:` limits delegated access and `user.permission:` 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:`, `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`. diff --git a/classes/api_token_scopes.go b/classes/api_token_scopes.go new file mode 100644 index 0000000..d8da97f --- /dev/null +++ b/classes/api_token_scopes.go @@ -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{} +} diff --git a/classes/api_tokens.go b/classes/api_tokens.go index a5f3d38..a5064ed 100644 --- a/classes/api_tokens.go +++ b/classes/api_tokens.go @@ -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) } } diff --git a/console/api_tokens.go b/console/api_tokens.go index eaabd52..34b187c 100644 --- a/console/api_tokens.go +++ b/console/api_tokens.go @@ -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) } diff --git a/controllers/api_tokens.go b/controllers/api_tokens.go index 7ff3f3e..e154edb 100644 --- a/controllers/api_tokens.go +++ b/controllers/api_tokens.go @@ -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) diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 0cae1ce..2a5c0ad 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 06c1605..37ab203 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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. diff --git a/member_token_scopes_smoke_test.go b/member_token_scopes_smoke_test.go new file mode 100644 index 0000000..43dc7a5 --- /dev/null +++ b/member_token_scopes_smoke_test.go @@ -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) + } + }) +} diff --git a/password_routes_test.go b/password_routes_test.go index a9cbcb0..c60c7dc 100644 --- a/password_routes_test.go +++ b/password_routes_test.go @@ -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 { diff --git a/plugin.go b/plugin.go index 05e7675..5f99343 100644 --- a/plugin.go +++ b/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 diff --git a/routes.go b/routes.go index fa6a1d1..c6076e9 100644 --- a/routes.go +++ b/routes.go @@ -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))