An action whose Applies failed was dropped with no signal, so a section whose Status succeeded showed a normal card with a button silently missing. The section is now also sent with error: true, and My account shows its error line. The error field's description, the cabana README and the My account docs say so; the OpenAPI document and the SPA schema types are regenerated.
25 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Users and permissions | Sign administrators in with JWT and cookie auth, declare permissions and navigation, and manage administrators from the console. | backend | 50 |
Users and permissions
The admin keeps WinterCMS's backend user model: the backend_users and backend_user_roles tables, roles with permission grants, and superusers who pass every check. cabana signs administrators in and checks their permissions; the tables are created by the framework migrations that migrate runs. Administrator emails are unique regardless of case. If a table copied from WinterCMS holds two emails that differ only in case, migrate stops and names their logins; change or remove one of them, then run migrate again.
Signing in
POST <prefix>/api/v1/auth/login checks the login and password against backend_users and issues a JWT for the admin audience, signed with admin.jwt.secret. Login attempts are throttled per admin.login.max_attempts and admin.login.decay_minutes. POST .../auth/refresh reissues a token inside the refresh window, and POST .../auth/logout revokes the current token by blacklisting its ID in backend_jwt_blacklist. Logout accepts a token whose access lifetime has run out as long as its refresh window is open, because .../auth/refresh would still accept it, and it always clears the session cookie.
The admin API accepts the token two ways:
- API clients send
Authorization: Bearer <token>. - The admin SPA sends
X-Requested-With: XMLHttpRequestand receives the token in an HttpOnly, SameSite=Strict cookie. A cookie-authenticated request that changes state must carry that header, which blocks cross-site request forgery.
The guard is registered in bouncer under the name backend and is the middleware of every admin route except login, refresh and the language bundle. cabana.Activate always registers its own guard under that name, so a plugin that registers another guard as backend makes the start-up fail with an error naming the plugin instead of replacing admin authentication. See Authentication for tokens and guards in general.
The admin keys go in config/admin.yaml, with the secret in the environment (SUMMER_ADMIN__JWT__SECRET):
jwt:
ttl: 60
refresh_ttl: 20160
password:
bcrypt_cost: 12
login:
max_attempts: 5
decay_minutes: 1
An application created with summer new already has a random SUMMER_ADMIN__JWT__SECRET in .env.
The cookie carries the Secure attribute. backend.cookie_secure: false drops it for plain-HTTP development and is refused in the production environment.
Permissions
A plugin declares its permissions with pact.HasPermissions, the Go form of registerPermissions, and its menu entries with pact.HasNavigation:
// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
}
}
// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "blog",
Label: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_posts"},
Controller: "acme.blog.posts",
SideMenu: []pact.NavigationItem{
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
},
}}
}
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. A plugin can also hide an entry per request with pact.NavigationItem.Visible (for a screen whose feature is turned off in its settings); the same drop and relink rules apply. 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). The last lines of the activation example on Admin controllers show it.
The admin registers two permissions of its own on the System tab of the permission editor: backend.manage_users (cabana.PermissionManageUsers) opens the Administrators screen and backend.manage_roles (cabana.PermissionManageRoles) the Roles screen. Neither is granted to any role in code, so after an upgrade only superusers reach those screens until an administrator grants a code to a role or to an administrator.
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.*.
Actions registered through pact.HasAdminActions, bulk actions (pact.HasAdminBulkActions) and record actions (pact.HasAdminRecordActions) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's meta.actions, and posting it answers 403 and is written to the authentication log. A bulk or record action that ran is logged with the controller, the action, the administrator and the affected count or record id, without record contents.
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.
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:
// 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.
Member personal API tokens, the ones the user plugin issues to frontend users, have their own catalog. A plugin declares scopes for them with pact.HasFrontendTokenScopes (pact.FrontendTokenScope: name, label and description, no permissions or methods, because the route's scope middleware decides). Those scopes appear only in the member token list and never in this admin catalog, and pact.HasTokenScopes scopes never appear in the member list. Both catalogs check names with pact.ValidTokenScopeName.
Expiry and revoke
Expiry is 30d, 90d (default), 365d or never. Soft revoke sets revoked_at; revoked tokens are hidden from the default list. A password reset does not invalidate personal API tokens.
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). Plugin routes that accept an admin Bearer token call cabana.ResolveBackend (see Authentication).
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. 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,passwordandpassword_confirmation(cabana.AdminAccountRequest). Any other key, such aslogin,role_id,is_superuser,permissionsoris_activated, answers 422 on that key and nothing is written. The login is shown but cannot be changed here. Role, superuser flag, permissions and activation are changed by another administrator on the Administrators screen, or 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.
-
Password: leave it blank to keep the current one. A new password needs
current_password, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equalpassword_confirmation. A wrong current password answers 422 oncurrent_passwordand is written to the authentication log. A changed password is hashed atadmin.password.bcrypt_costand ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again. -
Subtitle: a short line such as a role or position. It is stored where WinterCMS keeps it, under the
subtitlekey of the jsonablebackend_users.metadatacolumn, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it withcabana.BackendUser.Subtitle, which treats NULL,[]and malformed metadata as no subtitle.GET <prefix>/api/v1/auth/mereturns it assubtitle. -
Avatar:
POST <prefix>/api/v1/account/avatartakes one multipart part namedfile_data. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up tocabana.AvatarMaxBytes(2 MiB) and withinhttp.body_limits.upload_bytes; anything else answers 422 onavatar, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after.DELETE <prefix>/api/v1/account/avatarremoves the avatar and answers 200 also when there is none. The avatar is a public attachOne insystem_fileswith attachment typeBackend\Models\User(cabana.BackendUserMorphType) and fieldavatar(cabana.BackendUserAvatarField), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are.GET .../auth/mereturns itsurland a 250x250 cropthumb_url, and the top bar shows it in place of the initials. -
Preferences: the theme (system, light or dark) and the admin language an administrator picks in the SPA are saved through
PUT <prefix>/api/v1/account/preferencesand read withGET <prefix>/api/v1/account/preferences;GET .../auth/mereturns them aspreferences. The body may hold onlythemeandlocale(cabana.AdminPreferencesRequest); any other key, includingid, answers 422 on that key and nothing is written. Each present key is saved and an absent key keeps its value, so{}answers the current preferences.thememust besystem,lightordark;localemust be""(follow the request locale) or one of the admin localesGET /langlists inmeta.locales; another value or a non-string answers 422 on that field. They are stored underadmin_themeandadmin_localeinbackend_users.metadata, next to the subtitle; the defaults (system,"") are stored as absent keys, every other key survives a save, and metadata that is not a JSON object is never overwritten (422). Like the rest of My account the routes act only on the token's own administrator and refuse personal API tokens. -
API tokens: a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (
30d,90d,365dornever) and scopes fromGET /account/tokens/scopes; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See API tokens for the wire contract and scopes. -
Plugin sections: a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements
pact.HasAccountContributionsand returnspact.AccountContributionentries: a name, a label and description, an optional status line computed for the administrator, and buttons (pact.AccountAction) with an optional confirmation (danger variant included), extra permissions and anAppliesrule. The SPA loads them fromGET <prefix>/api/v1/account/contributionsand runs a button throughPOST <prefix>/api/v1/account/contributions/{name}/actions/{action}, which calls the action'sRunwith the signed-in administrator's id inside a transaction (cabana.TxFromContext) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Only an absolutehttp/httpsURL or a same-origin path is passed on; the admin drops anything else. A section whose status fails to load is still listed, markederror: true, and My account shows it with an error line; an action whoseAppliesfails is left out and marks its section the same way, so a missing button is explained. The other sections are unaffected. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged.
Plugins that show an administrator, such as an author box, read the subtitle with cabana.BackendUser.Subtitle and the picture with cabana.BackendUserAvatar, which returns the avatar file (or nil) for an administrator id.
Managing administrators
The Administrators screen
Settings → System → Administrators (summercms.cabana.users, cabana.AdministratorsControllerID) lists the backend administrators and opens a preview of each; it needs backend.manage_users. The list searches login, names and email and filters by role, superuser access, activation and last sign-in. The form has two tabs:
- Account: login, email, first and last name, the password with its confirmation, and the role. A password is required when an administrator is created and optional when one is edited (blank keeps the current one); it needs at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal confirmation, and is hashed at
admin.password.bcrypt_cost. A login or email another administrator already uses, in either field and ignoring case, is refused on the field; when only a deleted administrator holds it, the message says so, so you can restore that account instead. A new administrator is created activated, likeadmin:create, and no mail is sent. - Permissions: the superuser switch and the per-administrator permission overrides (inherit, allow, deny for each code in the catalog), stored in
backend_users.permissionsand applied last over the role's grants. While the superuser switch is on, the overrides are read-only (a fieldtrigger, see Triggers) and a save keeps the stored ones.
The server enforces these rules on every save, whatever the client sends:
- Only an administrator who is a superuser (signed in, or with a personal API token of full scope) sees superusers at all: for everyone else they are left out of the list, their ids answer 404, and the superuser switch, column and filter are not in the schemas. Sending
is_superuserwithout being a superuser is refused with 403, even when the field is hidden. - An administrator cannot grant more than they hold. Permissions they lack are locked in the override editor (a changed locked value is refused with 403), and roles whose grants (stored and code-declared) they do not hold themselves are locked in the role picker: assigning one is refused, while an administrator who already has such a role keeps it when other fields change.
- An administrator who is not a superuser cannot change, deactivate or delete an administrator whose grants they do not all hold themselves.
- On their own record an administrator may change login, email and names. Their own permission overrides are shown locked, with a note. A change of their own role, superuser flag or permission overrides is refused with 403, and so is a password: an administrator changes their own password in My account, which checks the current one.
- A superuser cannot be demoted when it is the last active superuser.
Administrators are deactivated, deleted and restored, never removed:
- Activate and Deactivate are actions on the preview and on the list selection; they switch
is_activated. Deactivate is not offered on your own record, and a selection that holds it is refused as a whole. - Delete is a soft delete through
deleted_at, so personal API tokens,linked_byreferences and attachments keep valid foreign keys. You cannot delete your own account. Deleted administrators stay in the list with a "Deleted" badge (the Deleted filter hides them or shows only them), and Restore brings one back with its role and permissions. - The last active superuser can be neither demoted, deactivated nor deleted, on the form, by an action or in a bulk selection. Every such write takes one database lock and counts the other active superusers inside its transaction, so two superusers removing each other at the same time leave one of them a superuser. The
admin:*commands never clear the superuser flag. - Deactivating or deleting an administrator ends their sessions at once (tokens issued before are refused) and stops their personal API tokens, because the guard refuses an inactive or deleted administrator. Activate and Restore do not issue anything new: the administrator signs in again, and their personal API tokens that are not revoked or expired work again.
When a refused row is part of a bulk selection, the whole selection is rolled back and the refusal is shown.
Setting the password of another administrator ends that administrator's sessions at once, as admin:reset-password does: tokens issued before the change are refused. Their personal API tokens keep working, exactly as after admin:reset-password; revoke them with admin:token:revoke when they should stop too.
The Roles screen
Settings → System → Roles (summercms.cabana.roles, cabana.RolesControllerID) lists, creates, edits and deletes the roles in backend_user_roles; it needs backend.manage_roles. A role has a name (unique), a code (filled from the name until you edit it, and unique too), a description, and on the Permissions tab the permission codes it grants, stored in the role's permissions.
- System roles are the roles with
is_systemset (the seeded developer and publisher roles) and every role whose code plugins grant permissions to throughpact.PermissionRoles. Their code cannot be changed and they cannot be deleted; name, description and stored grants stay editable. A role created on this screen is never a system role. The grants plugins declare in code are merged with the stored ones when an administrator signs in, so they apply whether or not they are stored. The role's editor shows them checked and locked, with the note that a plugin grants them; a save keeps them as stored. - An administrator who is not a superuser cannot grant more than they hold: permissions they lack are locked in the role's editor, so they can neither add nor remove them on any role.
- A role that any administrator holds, deleted administrators included, cannot be deleted; the refusal names the role and how many administrators hold it. Give them another role first. A refused role in a bulk delete rolls back the whole selection.
Operator commands
The application binary has commands for operators:
./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)
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:
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser
--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 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.