feat(16.3-01): admin:token CLI, party.Activated, READMEs and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
278
modules/cabana/token_commands.go
Normal file
278
modules/cabana/token_commands.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user