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

@@ -36,6 +36,10 @@ func delegateCommands() []bonfire.Command {
{"key:generate", "Print a fresh application key from the app binary", ""},
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD]"},
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked]"},
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>"},
{"admin:token:scopes", "List available personal API token scopes in the app binary", ""},
}
cmds := make([]bonfire.Command, 0, len(rows))
for _, r := range rows {

View File

@@ -79,15 +79,16 @@ func TestDelegateCommandsPassArgs(t *testing.T) {
func TestDelegateCommandsHelp(t *testing.T) {
hints := map[string][]string{
"migrate:rollback": {"--plugin"},
"queue:work": {"--queue"},
"schedule:run": {"--once"},
"serve": {"--addr"},
"admin:create": {"--email", "--superuser"},
"migrate:rollback": {"--plugin"},
"queue:work": {"--queue"},
"schedule:run": {"--once"},
"serve": {"--addr"},
"admin:create": {"--email", "--superuser"},
"admin:token:create": {"--name", "--scope", "--expires"},
}
cmds := delegateCommands()
if len(cmds) != 11 {
t.Fatalf("delegateCommands() has %d commands, want 11", len(cmds))
if len(cmds) != 15 {
t.Fatalf("delegateCommands() has %d commands, want 15", len(cmds))
}
got := captureAppRunner(t)
for _, c := range cmds {

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

View File

@@ -29,6 +29,7 @@ Use cases:
- Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide.
- Standalone bearer middleware: `bouncer.Middleware`.
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller).
@@ -82,7 +83,9 @@ func Login(secret string) (string, error) {
| Identifier | Description |
|------------|-------------|
| `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff and admin permission grants. |
| `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff, admin permission grants and optional `Scope`. |
| `bouncer.ScopeLimit` | A personal API token's method-filtered scope: `Full` or one permission-pattern slice per allowed scope. |
| `bouncer.Principal.Scope` | Set only for personal API token principals; nil for JWT sessions. |
| `bouncer.Guard` | Resolves the `bouncer.Principal` for a request. |
| `bouncer.CredentialGuard` | Resolves the principal and its underlying credential in one pass. |
| `bouncer.UnauthorizedWriter` | Optional guard interface for writing its own 401 response. |

View File

@@ -44,12 +44,13 @@ Use cases:
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
- Operator commands for creating administrators, resetting their passwords and managing personal API tokens (see CLI commands).
### Admin API routes
@@ -57,11 +58,14 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| Method and path | Purpose |
|-----------------|---------|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. Refresh refuses personal API tokens (`interactive_only`). |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). |
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current JWT, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). Logout refuses personal API tokens; `/auth/me` accepts any valid token. |
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Interactive session only (personal API tokens are refused). |
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Interactive session only. |
| GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. |
| DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. |
| GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
@@ -205,7 +209,12 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. |
| `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. |
| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. |
| `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. |
| `cabana.RuntimeCommands` | Returns the `admin:create`, `admin:reset-password` and `admin:token:*` commands. |
| `cabana.BackendAPIToken` | GORM model of `backend_api_tokens` (hash and hint only; plaintext is never stored). |
| `cabana.APITokenPrefix` | The `g15_` prefix of a personal API token plaintext. |
| `cabana.ResolveBackend` | Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or `g15_` token, never the admin cookie), with an optional scope cover check. |
| `cabana.ErrUnauthenticated` / `cabana.ErrTokenScope` | Sentinel errors from `cabana.ResolveBackend`. |
| `cabana.AdminAPIToken` / `cabana.AdminAPITokenScope` / `cabana.AdminAPITokenCreated` / `cabana.AdminAPITokenRequest` | JSON shapes of the account token API. |
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. |
| `cabana.FieldTypeAssets` / `cabana.FieldTypesResponse` | One entry of GET `/field-types` (`name`, `element`, `translatable`, `scripts`, `styles`) and the route's answer. |
@@ -297,20 +306,25 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds it. Without it the cap is the field's limit, or 128 MiB. |
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of create, update, settings, relation link/unlink, bulk delete, file caption and reorder, and relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
The backend user, role, token blacklist and personal API token tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`, `backend_api_tokens`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
## CLI commands
Both commands are added to every application binary by the generated `main` and open the database themselves when the application has not.
These commands are added to every application binary by the generated `main` and open the database themselves when the application has not.
| Command | Arguments and flags | Effect |
|---------|---------------------|--------|
| `admin:create` | `--email` (required), `--login` (defaults to the lower-cased email), `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. The password is read from a hidden prompt, or from stdin when it 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
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)
```
`--password` still works, but it prints a deprecation warning: a value on the command line is visible in the process list and stays in the shell history.

View File

@@ -16,7 +16,7 @@ import (
// RuntimeCommands returns the operator commands for backend administrators.
func RuntimeCommands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{
cmds := []bonfire.Command{
{
Name: "admin:create",
Description: "Create an activated backend administrator",
@@ -48,6 +48,7 @@ func RuntimeCommands(app *backpack.App) []bonfire.Command {
},
},
}
return append(cmds, tokenRuntimeCommands(app)...)
}
func adminCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {

View File

@@ -176,8 +176,9 @@ func commandApp(t *testing.T, gdb *gorm.DB) *backpack.App {
}
type flagInput struct {
args []string
flags map[string]string
args []string
flags map[string]string
multiFlags map[string][]string
}
func (f flagInput) Args() []string { return f.args }
@@ -186,6 +187,9 @@ func (f flagInput) Argument(name string) (string, bool) {
if name == "identifier" && len(f.args) > 0 {
return f.args[0], true
}
if name == "id" && len(f.args) > 0 {
return f.args[0], true
}
return "", false
}
@@ -194,7 +198,14 @@ func (f flagInput) Flag(name string) (string, bool) {
return value, ok && value != ""
}
func (f flagInput) Flags(string) []string { return nil }
func (f flagInput) Flags(name string) []string {
if f.multiFlags != nil {
if values, ok := f.multiFlags[name]; ok {
return values
}
}
return nil
}
// TestAdminCreateRejectsCrossFieldCollision pins WR-11: admin:create refuses a
// login that equals another admin's email and an email that equals another

View File

@@ -166,6 +166,67 @@ func withoutAction(actions []string, drop string) []string {
return out
}
// compilePermissionCatalog registers permissions and code-declared role
// grants from plugins onto reg. Shared by compileContributions and
// compileTokenCatalog.
func compilePermissionCatalog(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
if reg.permissions == nil {
reg.permissions = map[string]pact.Permission{}
}
if reg.roleGrants == nil {
reg.roleGrants = map[string]map[string]bool{}
}
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasPermissions)
if !ok || src == nil {
continue
}
for _, permission := range src.Permissions() {
if !permissionCode(permission.Code, false) {
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
}
if _, exists := reg.permissions[permission.Code]; exists {
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
}
reg.permissions[permission.Code] = permission
for _, role := range permission.Roles {
role = strings.TrimSpace(role)
if role == "" {
return fmt.Errorf("cabana: permission %s has an empty role", permission.Code)
}
if reg.roleGrants[role] == nil {
reg.roleGrants[role] = map[string]bool{}
}
reg.roleGrants[role][permission.Code] = true
}
}
}
return nil
}
// compileTokenCatalog returns a Registry with permissions, role grants and the
// token scope catalog only (no controllers, settings or navigation), so CLI
// commands can see plugin scopes without compiling admin UI contributions.
func compileTokenCatalog(plugins []party.Plugin) (*Registry, error) {
reg := &Registry{
permissions: map[string]pact.Permission{},
roleGrants: map[string]map[string]bool{},
}
if err := compilePermissionCatalog(reg, plugins); err != nil {
return nil, err
}
if err := compileTokenScopes(reg, plugins); err != nil {
return nil, err
}
return reg, nil
}
func compileContributions(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
@@ -175,31 +236,13 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
reg.settings = map[string]*CompiledSetting{}
seenNavigation := map[string]struct{}{}
if err := compilePermissionCatalog(reg, plugins); err != nil {
return err
}
for _, plugin := range plugins {
if plugin == nil {
continue
}
if src, ok := plugin.(pact.HasPermissions); ok && src != nil {
for _, permission := range src.Permissions() {
if !permissionCode(permission.Code, false) {
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
}
if _, exists := reg.permissions[permission.Code]; exists {
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
}
reg.permissions[permission.Code] = permission
for _, role := range permission.Roles {
role = strings.TrimSpace(role)
if role == "" {
return fmt.Errorf("cabana: permission %s has an empty role", permission.Code)
}
if reg.roleGrants[role] == nil {
reg.roleGrants[role] = map[string]bool{}
}
reg.roleGrants[role][permission.Code] = true
}
}
}
if src, ok := plugin.(pact.HasSettings); ok && src != nil {
assets, hasAssets := plugin.(pact.AdminAssets)
for _, item := range src.Settings() {

View File

@@ -0,0 +1,278 @@
package cabana
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
func tokenRuntimeCommands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{
{
Name: "admin:token:create",
Description: "Create a personal API token for a backend administrator",
Args: []bonfire.Arg{{
Name: "identifier", Description: "Login or email", Required: true,
}},
Flags: []bonfire.Flag{
{Name: "name", Description: "Token name"},
{Name: "scope", Description: "Scope name (repeatable)", Repeatable: true},
{Name: "expires", Description: "Expiry: 30d, 90d, 365d, never, or YYYY-MM-DD", Default: "90d"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminTokenCreate(ctx, app, in, out)
},
},
{
Name: "admin:token:list",
Description: "List personal API tokens for a backend administrator (or all)",
Args: []bonfire.Arg{{
Name: "identifier", Description: "Login or email (optional)",
}},
Flags: []bonfire.Flag{
{Name: "revoked", Description: "Include revoked tokens", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminTokenList(ctx, app, in, out)
},
},
{
Name: "admin:token:revoke",
Description: "Revoke a personal API token by id",
Args: []bonfire.Arg{{
Name: "id", Description: "Token id", Required: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminTokenRevoke(ctx, app, in, out)
},
},
{
Name: "admin:token:scopes",
Description: "List available personal API token scopes",
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminTokenScopes(ctx, app, in, out)
},
},
}
}
func tokenCatalogForApp(app *backpack.App) (*Registry, error) {
plugins := []party.Plugin{}
if app != nil {
if activated, ok := app.Lookup[party.Activated](); ok && activated != nil {
plugins = []party.Plugin(activated)
}
}
return compileTokenCatalog(plugins)
}
func adminTokenCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
identifier := argValue(in, "identifier")
if identifier == "" {
return errors.New("cabana: an identifier is required")
}
name := strings.TrimSpace(flagValue(in, "name"))
expires := strings.TrimSpace(flagValue(in, "expires"))
scopes := in.Flags("scope")
if len(scopes) == 0 {
if s := flagValue(in, "scope"); s != "" {
scopes = []string{s}
}
}
catalog, err := tokenCatalogForApp(app)
if err != nil {
return err
}
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
user, found, err := findBackendLogin(gdb.WithContext(ctx), identifier)
if err != nil {
return err
}
if !found {
return fmt.Errorf("cabana: admin %q not found", identifier)
}
if !user.IsActivated || user.DeletedAt.Valid {
return fmt.Errorf("cabana: admin %q is deactivated or deleted", identifier)
}
principal, err := (BackendUsers{DB: gdb, Registry: catalog}).FindByID(ctx, user.ID)
if err != nil || principal == nil {
return fmt.Errorf("cabana: could not load admin %q", identifier)
}
plain, _, details, err := issueAPIToken(gdb, catalog, principal, name, expires, scopes, time.Now().UTC(), true)
if err != nil {
return err
}
if details != nil {
return fmt.Errorf("cabana: %s", firstDetail(details))
}
out.Println(plain)
return nil
})
}
func adminTokenList(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
identifier := argValue(in, "identifier")
showRevoked := flagValue(in, "revoked") == "true"
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
q := gdb.WithContext(ctx).Model(&BackendAPIToken{}).Order("created_at DESC").Order("id DESC")
if identifier != "" {
user, found, err := findBackendLogin(gdb.WithContext(ctx), identifier)
if err != nil {
return err
}
if !found {
return fmt.Errorf("cabana: admin %q not found", identifier)
}
q = q.Where("backend_user_id = ?", user.ID)
}
if !showRevoked {
q = q.Where("revoked_at IS NULL")
}
var rows []BackendAPIToken
if err := q.Find(&rows).Error; err != nil {
return err
}
headers := []string{"id", "login", "name", "scopes", "hint", "created", "last used", "expires", "revoked"}
table := make([][]string, 0, len(rows))
for i := range rows {
row := &rows[i]
var login string
_ = gdb.WithContext(ctx).Raw(`SELECT login FROM backend_users WHERE id = ?`, row.BackendUserID).Scan(&login).Error
scopes := ""
if row.Scopes.Valid {
scopes = strings.Join(row.Scopes.Get(), ",")
}
table = append(table, []string{
strconv.FormatUint(uint64(row.ID), 10),
login,
row.Name,
scopes,
APITokenPrefix + "…" + row.TokenHint,
formatTime(row.CreatedAt),
formatTimePtr(row.LastUsedAt),
formatTimePtr(row.ExpiresAt),
formatTimePtr(row.RevokedAt),
})
}
out.Table(headers, table)
return nil
})
}
func adminTokenRevoke(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
raw := argValue(in, "id")
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
return errors.New("cabana: a token id is required")
}
return withAdminDB(ctx, app, func(gdb *gorm.DB) error {
var row BackendAPIToken
err := gdb.WithContext(ctx).First(&row, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("cabana: token %d not found", id)
}
if err != nil {
return err
}
if row.RevokedAt != nil {
out.Info(fmt.Sprintf("token %d was already revoked", id))
return nil
}
now := time.Now().UTC()
if err := gdb.WithContext(ctx).Model(&row).Updates(map[string]any{
"revoked_at": now,
"revoked_by": nil,
}).Error; err != nil {
return err
}
out.Success(fmt.Sprintf("revoked token %d", id))
return nil
})
}
func adminTokenScopes(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {
catalog, err := tokenCatalogForApp(app)
if err != nil {
return err
}
tr, _ := app.Lookup[*phrasebook.Translator]()
headers := []string{"name", "label", "read-only", "permissions"}
rows := make([][]string, 0, len(catalog.tokenScopes.ordered))
for _, scope := range catalog.tokenScopes.ordered {
label := scope.Label
if strings.Contains(label, "::") && tr != nil && tr.Has(label) {
if s := tr.Get(ctx, label, nil); s != "" {
label = s
}
}
readOnly := "yes"
for m := range scope.Methods {
if m != "GET" && m != "HEAD" {
readOnly = "no"
break
}
}
if scope.Full && scope.Name == scopeAll {
readOnly = "no"
}
perms := strings.Join(scope.Permissions, ",")
if scope.Full {
perms = "(full)"
}
rows = append(rows, []string{scope.Name, label, readOnly, perms})
}
out.Table(headers, rows)
return nil
}
func argValue(in bonfire.Input, name string) string {
if in == nil {
return ""
}
if v, ok := in.Argument(name); ok {
return strings.TrimSpace(v)
}
if name == "identifier" && len(in.Args()) > 0 {
return strings.TrimSpace(in.Args()[0])
}
if name == "id" && len(in.Args()) > 0 {
return strings.TrimSpace(in.Args()[0])
}
return ""
}
func firstDetail(details map[string]any) string {
for _, raw := range details {
if keys, ok := raw.([]string); ok && len(keys) > 0 {
key := keys[0]
if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok {
return fmt.Sprintf("scope %q is not available", name)
}
return key
}
}
return "validation failed"
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func formatTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return formatTime(*t)
}

View File

@@ -1,6 +1,8 @@
package cabana_test
import (
"bytes"
"context"
"encoding/json"
"io/fs"
"net/http"
@@ -12,6 +14,7 @@ import (
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
@@ -249,6 +252,79 @@ func TestAccountTokensSmoke(t *testing.T) {
}
}
func TestTokenCommandsSmoke(t *testing.T) {
gdb := adminGorm(t)
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
t.Fatal(err)
}
app := commandApp(t, gdb)
demo := tokenDemoPlugin{fsys: tokenDemoFS()}
if err := app.Publish(party.Activated{demo}); err != nil {
t.Fatal(err)
}
user := insertAdmin(t, gdb, "tok-cli", "tok-cli@example.test", "secret-pass", true, false)
create := commandByName(t, cabana.RuntimeCommands(app), "admin:token:create")
var createOut bytes.Buffer
if err := create.Run(context.Background(), flagInput{
args: []string{user.Login},
flags: map[string]string{
"name": "cli-agent",
"expires": "90d",
},
multiFlags: map[string][]string{"scope": {"demo"}},
}, bonfire.NewOutput(nil, &createOut, &bytes.Buffer{})); err != nil {
t.Fatalf("admin:token:create: %v", err)
}
plain := strings.TrimSpace(createOut.String())
if !wellFormedPlain(plain) {
t.Fatalf("create stdout %q", createOut.String())
}
if strings.Count(createOut.String(), "\n") != 1 || !strings.HasSuffix(createOut.String(), "\n") {
t.Fatalf("create must print exactly one token line, got %q", createOut.String())
}
list := commandByName(t, cabana.RuntimeCommands(app), "admin:token:list")
var listOut bytes.Buffer
if err := list.Run(context.Background(), flagInput{
args: []string{user.Login},
}, bonfire.NewOutput(nil, &listOut, &bytes.Buffer{})); err != nil {
t.Fatalf("admin:token:list: %v", err)
}
listText := listOut.String()
if strings.Contains(listText, plain) {
t.Fatal("list leaked plaintext token")
}
hint := plain[len(plain)-4:]
if !strings.Contains(listText, "g15_…"+hint) {
t.Fatalf("list missing hint, got %q", listText)
}
var row cabana.BackendAPIToken
if err := gdb.Where("backend_user_id = ? AND revoked_at IS NULL", user.ID).First(&row).Error; err != nil {
t.Fatal(err)
}
revoke := commandByName(t, cabana.RuntimeCommands(app), "admin:token:revoke")
var revokeOut bytes.Buffer
if err := revoke.Run(context.Background(), flagInput{
args: []string{strconv.FormatUint(uint64(row.ID), 10)},
}, bonfire.NewOutput(nil, &revokeOut, &bytes.Buffer{})); err != nil {
t.Fatalf("admin:token:revoke: %v", err)
}
scopes := commandByName(t, cabana.RuntimeCommands(app), "admin:token:scopes")
var scopesOut bytes.Buffer
if err := scopes.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &scopesOut, &bytes.Buffer{})); err != nil {
t.Fatalf("admin:token:scopes: %v", err)
}
scopesText := scopesOut.String()
for _, name := range []string{"*", "admin:read", "demo"} {
if !strings.Contains(scopesText, name) {
t.Fatalf("scopes missing %q in %q", name, scopesText)
}
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)

View File

@@ -136,7 +136,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. |
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. |
| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. |
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, seeds the system roles, adds a case-insensitive unique index on `backend_users.email` and adds WinterCMS's nullable jsonable `backend_users.metadata` column (TEXT; the admin subtitle lives under its `subtitle` key). The index migration refuses to run while emails differ only in case and names the clashing logins. Rolling back the metadata migration drops only that column. |
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, the `backend_api_tokens` table for personal API tokens, seeds the system roles, adds a case-insensitive unique index on `backend_users.email` and adds WinterCMS's nullable jsonable `backend_users.metadata` column (TEXT; the admin subtitle lives under its `subtitle` key). The index migration refuses to run while emails differ only in case and names the clashing logins. Rolling back the metadata migration drops only that column. |
| `lagoon.QueueMigrations` | Migrates River's schema to `lagoon.RiverSchemaVersion` and creates the `summer_jobs` job record table. |
| `lagoon.QueueHistoryID` | History id of the job-queue set, `summercms.conga`. |
| `lagoon.JobsTable` | Name of the job record table, `summer_jobs`. |

View File

@@ -25,6 +25,7 @@ Use cases:
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- 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.
- 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.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
@@ -112,6 +113,9 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasLangOverrides` | Replaces or adds translations of any loaded namespace, including the framework's own. |
| `pact.HasMailTemplates` | Ships mail templates and layout aliases. |
| `pact.Permission` | One backend permission entry (code, tab, label, roles). |
| `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.NavigationItem` | One backend navigation entry, with an optional side menu. |
| `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. |

View File

@@ -26,7 +26,7 @@ Use cases:
- A process-wide, concurrency-safe registry filled by `party.Register` (nil plugins are ignored).
- `party.Activate` selects plugins by manifest ID and fails on an empty ID, a duplicate ID, an unregistered plugin, a missing requirement or a dependency cycle.
- Stable topological ordering: plugins without a dependency relation keep their manifest order.
- Activation sequence: records the ordered IDs on the `backpack.App`, merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)) and mailer ([postcard](../postcard/README.md)), then registers each plugin's mail templates and runs its Boot.
- Activation sequence: records the ordered IDs on the `backpack.App`, publishes `party.Activated` (the ordered plugin list for commands that need plugin scopes at Run time), merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)) and mailer ([postcard](../postcard/README.md)), then registers each plugin's mail templates and runs its Boot.
## Usage
@@ -73,6 +73,7 @@ if err != nil {
| `party.Plugin` | Interface every compiled plugin implements: ID, required plugin IDs, Register and Boot. |
| `party.Register` | Adds a plugin to the process-wide registry; called from the plugin's `init`. |
| `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail and Boot steps; returns the ordered plugins. |
| `party.Activated` | The ordered `[]party.Plugin` published by `party.Activate`; commands look it up at Run time with `app.Lookup[party.Activated]()`. |
## Dependencies

View File

@@ -19,6 +19,10 @@ type Plugin interface {
Boot(app *backpack.App) error
}
// Activated is the ordered plugin list published by Activate. Commands look
// it up at Run time with app.Lookup[Activated]().
type Activated []Plugin
var (
mu sync.Mutex
plugins []Plugin
@@ -105,6 +109,9 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e
orderedIDs[i] = p.ID()
}
app.SetPlugins(orderedIDs)
if err := app.Publish(Activated(ordered)); err != nil {
return nil, fmt.Errorf("party: %w", err)
}
for _, p := range ordered {
if hc, ok := p.(pact.HasConfig); ok && app.Config != nil {

View File

@@ -305,6 +305,26 @@ func TestActivateNilAppRejected(t *testing.T) {
}
}
func TestActivatePublishesPlugins(t *testing.T) {
var log []string
a := &tracePlugin{id: "golem15.a", log: &log}
b := &tracePlugin{id: "golem15.b", req: []string{"golem15.a"}, log: &log}
app := backpack.New(&compass.Config{})
if _, err := activate([]Plugin{b, a}, app, []string{"golem15.b", "golem15.a"}); err != nil {
t.Fatalf("activate: %v", err)
}
activated, ok := app.Lookup[Activated]()
if !ok || activated == nil {
t.Fatal("Activate did not publish Activated")
}
if len(activated) != 2 {
t.Fatalf("Activated has %d plugins, want 2", len(activated))
}
if activated[0].ID() != "golem15.a" || activated[1].ID() != "golem15.b" {
t.Fatalf("Activated order = %q, %q; want golem15.a then golem15.b", activated[0].ID(), activated[1].ID())
}
}
func TestRegisterNilIsNoop(t *testing.T) {
Register(nil)
}