feat(16.3-01): admin:token CLI, party.Activated, READMEs and docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 01:55:07 +02:00
parent ffdb5d00ca
commit e24e03c47d
22 changed files with 627 additions and 54 deletions

View File

@@ -66,7 +66,7 @@ func (p *BlogPlugin) Navigation() []pact.NavigationItem {
}
```
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. `cabana.Allows` is the check and follows Winter's `hasAnyAccess`: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. `cabana.Allows` is the check and follows Winter's `hasAnyAccess` for JWT sessions: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. When the request carries a personal API token with a non-full scope, the scope is checked first (see [API tokens](#api-tokens)). The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.
@@ -74,9 +74,66 @@ Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminB
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
## API tokens
Administrators can mint personal API tokens for agents and integrations. A token authenticates as its owner through the admin `backend` guard when the `Authorization` header carries `Bearer g15_…` (forty lowercase hex characters after the `g15_` prefix). Only the sha256 hash and the last four characters (the hint) are stored in `backend_api_tokens`; the plaintext appears once, in the create response or on the CLI stdout line.
### Scopes
Each token holds one or more scopes chosen at creation. The framework registers two scopes:
| Scope | Methods | Effect |
|-------|---------|--------|
| `*` | every method | Acts as the owner with no further scope limit. |
| `admin:read` | GET, HEAD | Acts as the owner on read methods only. |
Plugins declare more scopes with `pact.HasTokenScopes`. Boot fails on a duplicate name, a reserved name (`*`, `admin`, `admin:…`), an invalid name, empty permissions or methods, an unknown method, a method list without GET, an unknown permission pattern, or a label or description phrase key that does not resolve:
```go src=docs/examples/blog/token_scopes.go#Plugin.TokenScopes
// TokenScopes registers personal API token scopes for the blog plugin.
func (p *Plugin) TokenScopes() []pact.TokenScope {
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
return []pact.TokenScope{
{
Name: "blog",
Label: "acme.blog::lang.scopes.blog",
Description: "acme.blog::lang.scopes.blog_description",
Permissions: []string{"acme.blog.*"},
Methods: all,
},
{
Name: "blog:read",
Label: "acme.blog::lang.scopes.blog_read",
Description: "acme.blog::lang.scopes.blog_read_description",
Permissions: []string{"acme.blog.*"},
Methods: []string{"GET", "HEAD"},
},
}
}
```
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.
### 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.
### Account endpoints
Under `<prefix>/api/v1`, interactive sessions only:
| Method and path | Purpose |
|-----------------|---------|
| `GET /account/tokens` | List the caller's tokens (newest first; `?revoked=1` includes revoked). |
| `POST /account/tokens` | Create a token; answers 201 with the plaintext once. |
| `DELETE /account/tokens/{id}` | Soft-revoke one of the caller's own active tokens. |
| `GET /account/tokens/scopes` | Scopes the caller may assign. |
Operators manage any user's tokens with `admin:token:create`, `admin:token:list`, `admin:token:revoke` and `admin:token:scopes` on the application binary (see [Setup and maintenance](../console/setup-and-maintenance.md#admin-accounts)). Plugin routes that accept an admin Bearer token call `cabana.ResolveBackend` (see [Authentication](../services/authentication.md)).
## My account
Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `<prefix>/account` from the user menu. It saves through `PUT <prefix>/api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body.
Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `<prefix>/account` from the user menu. It saves through `PUT <prefix>/api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body. Personal API tokens are refused on this route and on the avatar routes.
- **Editable:** first name, last name, email and subtitle, plus the password. The body may hold only `first_name`, `last_name`, `email`, `subtitle`, `current_password`, `password` and `password_confirmation` (`cabana.AdminAccountRequest`). Any other key, such as `login`, `role_id`, `is_superuser`, `permissions` or `is_activated`, answers 422 on that key and nothing is written. The login is shown but cannot be changed here, and role, superuser flag, permissions and activation stay with the operator commands below.
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
@@ -88,14 +145,15 @@ Plugins that show an administrator, such as an author box, read the subtitle wit
## Managing administrators
The application binary has two commands for operators:
The application binary has commands for operators:
```sh
./bin/acme admin:create --email admin@example.com --superuser
./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)
```
Both commands ask for the password at a prompt that does not echo it. In a script, pipe it on stdin so it never appears in the process list or the shell history:
`admin:create` and `admin:reset-password` ask for the password at a prompt that does not echo it. In a script, pipe it on stdin so it never appears in the process list or the shell history:
```sh
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser
@@ -103,4 +161,4 @@ printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.
`--password` is still accepted but deprecated: the command prints a warning, because the value is visible to other users in the process list and stays in the shell history.
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. It refuses a login or email that matches another administrator's login or email in either field, because a sign-in identifier that matches two administrators is answered like a wrong password. `admin:reset-password` takes a login or an email, sets the password and revokes every token issued before the reset. Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working.
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. It refuses a login or email that matches another administrator's login or email in either field, because a sign-in identifier that matches two administrators is answered like a wrong password. `admin:reset-password` takes a login or an email, sets the password and revokes every JWT issued before the reset (personal API tokens stay valid). Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working. The four `admin:token:*` commands are listed under [Admin accounts](../console/setup-and-maintenance.md#admin-accounts).

View File

@@ -45,6 +45,10 @@ During development you often work from the application directory with `summer` a
| `summer key:generate` | `./bin/acme key:generate` |
| `summer admin:create` | `./bin/acme admin:create` |
| `summer admin:reset-password` | `./bin/acme admin:reset-password` |
| `summer admin:token:create` | `./bin/acme admin:token:create` |
| `summer admin:token:list` | `./bin/acme admin:token:list` |
| `summer admin:token:revoke` | `./bin/acme admin:token:revoke` |
| `summer admin:token:scopes` | `./bin/acme admin:token:scopes` |
`summer` parses no flags of these commands: every flag and argument, including `--help`, goes to the binary unchanged, and the binary's input is your terminal, so the password prompts of `admin:create` and `admin:reset-password` work as usual:

View File

@@ -72,16 +72,21 @@ From the application directory, `summer serve --addr 127.0.0.1:8123` passes the
| Command | Arguments and flags | Purpose |
|---------|---------------------|---------|
| `admin:create` | `--email` (required), `--login`, `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. `--login` defaults to the lower-cased email. The password is read from a hidden prompt, or from stdin when the input is not a terminal. |
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every token issued before the reset. |
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every JWT issued before the reset. Personal API tokens are unchanged. |
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`) | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout. |
| `admin:token:list` | `[<identifier>]`, `--revoked` | Lists tokens (hint only, never the hash or plaintext). |
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. |
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
```sh
./bin/acme admin:create --email admin@example.com --superuser
./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)
```
Both commands prompt for the password without echoing it. In a script, pipe it on stdin, for example `printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser`. The `--password` flag still works but is deprecated and prints a warning, because the value stays in your shell history and is visible in the process list. The admin is described in [cabana](../../modules/cabana/README.md).
`admin:create` and `admin:reset-password` prompt for the password without echoing it. In a script, pipe it on stdin, for example `printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser`. The `--password` flag still works but is deprecated and prints a warning, because the value stays in your shell history and is visible in the process list. The admin and personal API tokens are described in [cabana](../../modules/cabana/README.md) and [Users and permissions](../backend/users-and-permissions.md#api-tokens).
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included.
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included. The four `admin:token:*` commands have matching `summer` delegates.
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning at start-up naming `summer admin:create`.

View File

@@ -3,6 +3,11 @@ plugin:
description: A simple blog.
permissions:
access_posts: Manage blog posts
scopes:
blog: Blog
blog_description: Full access to blog admin APIs.
blog_read: Blog (read)
blog_read_description: Read-only access to blog admin APIs.
posts:
title: Posts
post: Post

View File

@@ -0,0 +1,26 @@
package blog
import "git.golem15.com/golem15/summercms/modules/pact"
var _ pact.HasTokenScopes = (*Plugin)(nil)
// TokenScopes registers personal API token scopes for the blog plugin.
func (p *Plugin) TokenScopes() []pact.TokenScope {
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
return []pact.TokenScope{
{
Name: "blog",
Label: "acme.blog::lang.scopes.blog",
Description: "acme.blog::lang.scopes.blog_description",
Permissions: []string{"acme.blog.*"},
Methods: all,
},
{
Name: "blog:read",
Label: "acme.blog::lang.scopes.blog_read",
Description: "acme.blog::lang.scopes.blog_read_description",
Permissions: []string{"acme.blog.*"},
Methods: []string{"GET", "HEAD"},
},
}
}

View File

@@ -82,6 +82,7 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl
| `pact.HasLang`, `pact.HasLangOverrides` | Translations, and overrides of other namespaces. |
| `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.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. |

View File

@@ -62,6 +62,8 @@ Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfte
A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience.
The admin API's `backend` guard is composite: an `Authorization: Bearer` value that starts with `g15_` is looked up as a personal API token (see [API tokens](../backend/users-and-permissions.md#api-tokens)); every other request uses the unchanged JWT path (Bearer JWT, then the admin cookie). A `g15_` value is never read from a cookie or the query string.
Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`:
```go src=modules/bouncer/example_test.go#ExampleNewJWTGuard
@@ -103,6 +105,10 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
### Accepting admin tokens on plugin routes
Plugin routes that should accept an admin JWT or a personal API token call `cabana.ResolveBackend`. It reads only the Authorization Bearer header (never the admin cookie), applies the token's method filter, and for a scoped token requires a method-allowed scope that is full or matches every cover code you pass. On success it returns the request with `bouncer.WithUser` (and `bouncer.WithCredential` for a token). Failures are `cabana.ErrUnauthenticated` or `cabana.ErrTokenScope`. Routes registered before `cabana.Activate` must call it per request, because the resolver is published at activation.
## Passwords
`bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working:

View File

@@ -251,6 +251,11 @@ plugin:
description: A simple blog.
permissions:
access_posts: Manage blog posts
scopes:
blog: Blog
blog_description: Full access to blog admin APIs.
blog_read: Blog (read)
blog_read_description: Read-only access to blog admin APIs.
posts:
title: Posts
post: Post