diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index e3ccc599..6c3e0cf6 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -116,6 +116,8 @@ func (p *Plugin) TokenScopes() []pact.TokenScope { A scoped request passes only when one method-allowed scope matches every required permission code and the owner holds a required code inside that scope. A superuser with a scoped token is limited to that scope. An empty requirement list is refused unless the scope is full (`*` or `admin:read` on GET/HEAD). Routes that never call `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) apply the same empty-list rule. Account profile, avatar, token management, refresh and logout refuse every personal token (`details.reason` `interactive_only`), including `*`. `GET /auth/me` accepts any valid token. +Member personal API tokens, the ones the user plugin issues to frontend users, have their own catalog. A plugin declares scopes for them with `pact.HasFrontendTokenScopes` (`pact.FrontendTokenScope`: name, label and description, no permissions or methods, because the route's scope middleware decides). Those scopes appear only in the member token list and never in this admin catalog, and `pact.HasTokenScopes` scopes never appear in the member list. Both catalogs check names with `pact.ValidTokenScopeName`. + ### Expiry and revoke Expiry is `30d`, `90d` (default), `365d` or `never`. Soft revoke sets `revoked_at`; revoked tokens are hidden from the default list. A password reset does not invalidate personal API tokens. diff --git a/docs/plugins/registration.md b/docs/plugins/registration.md index e9268127..a808de2a 100644 --- a/docs/plugins/registration.md +++ b/docs/plugins/registration.md @@ -83,6 +83,7 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl | `pact.HasMailTemplates` | Mail templates and layouts. | | `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. | | `pact.HasTokenScopes` | Personal API token scopes (`pact.TokenScope`) compiled at admin boot. | +| `pact.HasFrontendTokenScopes` | Member personal API token scopes (`pact.FrontendTokenScope`) a route's `user.scope:` middleware checks, compiled by the user plugin; the admin token catalog never reads them. | | `pact.HasFrontendPermissions` | Frontend permission codes (`pact.FrontendPermission`) a route's permission middleware checks, offered in the user plugin's permission editors. | | `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. | | `pact.HasAdminActionExtensions` | Record actions added to any admin controller by ID (`pact.AdminActionExtension`); a `pact.AdminRecordAction` with a `Prompt` asks the administrator for one text value first and receives it as `pact.AdminRecordActionInput.Value`. | diff --git a/modules/cabana/token_scopes.go b/modules/cabana/token_scopes.go index 39663824..38bd1d8e 100644 --- a/modules/cabana/token_scopes.go +++ b/modules/cabana/token_scopes.go @@ -2,7 +2,6 @@ package cabana import ( "fmt" - "regexp" "strings" "git.golem15.com/golem15/summercms/modules/bouncer" @@ -16,8 +15,6 @@ const ( scopeAdminRead = "admin:read" ) -var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`) - var allowedTokenMethods = map[string]bool{ "GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true, } @@ -107,7 +104,7 @@ func compileTokenScopes(reg *Registry, plugins []party.Plugin) error { func validatePluginTokenScope(reg *Registry, pluginID string, scope pact.TokenScope, cat *tokenScopeCatalog) error { name := strings.TrimSpace(scope.Name) - if name == "" || !tokenScopeNameRE.MatchString(name) { + if name == "" || !pact.ValidTokenScopeName(name) { return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name) } if name == scopeAll || name == "admin" || strings.HasPrefix(name, "admin:") { diff --git a/modules/pact/README.md b/modules/pact/README.md index 85818b6b..7c7d10c6 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -26,6 +26,7 @@ Use cases: - HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`. - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed). - Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token. +- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply. - Frontend permission codes: `pact.FrontendPermission` and `pact.HasFrontendPermissions`, the frontend counterpart of `pact.HasPermissions`, so a plugin guarding a route with a permission code also declares that code for the user plugin's permission editors. - MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list. - My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only. @@ -122,6 +123,9 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.HasPermissions` | Declares backend permissions. | | `pact.TokenScope` | One personal API token scope: name, label, description, permission patterns and HTTP methods. | | `pact.HasTokenScopes` | Declares personal API token scopes compiled at admin boot. | +| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:` middleware checks), `Label` and `Description`. | +| `pact.HasFrontendTokenScopes` | Declares member token scopes, the frontend counterpart of `pact.HasTokenScopes`; compiled by the plugin that owns member tokens, never by the admin token catalog. | +| `pact.ValidTokenScopeName` | Reports whether a scope name is valid (`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`); shared by both token catalogs. | | `pact.NavigationItem` | One backend navigation entry, with an optional side menu and an optional `Visible` func the admin runs on every navigation request. | | `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. | | `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. | @@ -202,7 +206,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { - SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`). - Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`. -- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `strings`, `time`. +- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `regexp`, `strings`, `time`. ## Testing diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index b6e48d0c..956e0778 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -6,6 +6,7 @@ import ( "io" "io/fs" "net/http" + "regexp" "strings" "time" @@ -653,6 +654,39 @@ type HasTokenScopes interface { TokenScopes() []TokenScope } +// FrontendTokenScope is one scope of the member personal API tokens (usr_) +// that the user plugin issues, declared by a plugin: the name a route's +// user.scope: middleware checks, with the label and description the +// member token screens show. Label and Description are phrase keys +// (containing "::") or literal text. It is the frontend counterpart of +// TokenScope and carries no permission patterns or method filter: the route +// middleware alone decides what a member scope allows. +type FrontendTokenScope struct { + Name string + Label string + Description string +} + +// HasFrontendTokenScopes is implemented by plugins that declare member +// personal API token scopes; the frontend counterpart of HasTokenScopes. The +// framework's admin token catalog never reads it: the plugin that owns member +// tokens compiles it, validates the names with ValidTokenScopeName and fails +// boot on a bad one. +type HasFrontendTokenScopes interface { + FrontendTokenScopes() []FrontendTokenScope +} + +var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`) + +// ValidTokenScopeName reports whether name is a valid personal API token +// scope name: lowercase, starting with a letter, letters, digits, "_" and "-", +// with at most one ":" separating two such parts (for example "blog:write"). +// It is the one name rule both the admin token catalog (TokenScope) and the +// member token catalog (FrontendTokenScope) apply. +func ValidTokenScopeName(name string) bool { + return tokenScopeNameRE.MatchString(name) +} + // NavigationItem is one registerNavigation() entry. Controller is the admin // controller ID; the SPA derives its route from that ID. Every field but // Visible is static and compiled once at boot; Visible is the one hook the