Files
summercms/docs/backend/users-and-permissions.md
Jakub Zych 64d21c8caf feat(cabana): add backend user avatars to My account and the top bar
- cabana.BackendUser is an attach.Owner (MorphName Backend\Models\User,
  cabana.BackendUserMorphType) with one public attachOne avatar
  (cabana.BackendUserAvatarField), as WinterCMS stores it
- POST /account/avatar stores one file_data image (jpg, jpeg, png, gif or
  webp, image guard, at most cabana.AvatarMaxBytes = 2 MiB, under a
  server-generated name) and replaces the previous avatar: old rows are
  deleted in the transaction, their blobs after the commit
- DELETE /account/avatar removes the avatar and is idempotent
- GET /auth/me and the account routes return avatar {url, thumb_url}
  (250x250 crop); cabana.BackendUserAvatar lets plugins read it
- swag annotations, regenerated admin.json and schema.d.ts; route
  inventories, CSRF walk (29) and OpenAPI conformance learn both routes
- SPA: avatar upload and remove on My account, avatar image in the user
  menu trigger and header; backend::lang.myaccount avatar strings
- rebuilt boardwalk dist; cabana README, users-and-permissions and
  admin-spa docs
2026-10-09 17:45:43 +02:00

11 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: XMLHttpRequest and 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. 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 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.*.

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.

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.

  • 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.
  • 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 equal password_confirmation. A wrong current password answers 422 on current_password and is written to the authentication log. A changed password is hashed at admin.password.bcrypt_cost and 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 subtitle key of the jsonable backend_users.metadata column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with cabana.BackendUser.Subtitle, which treats NULL, [] and malformed metadata as no subtitle. GET <prefix>/api/v1/auth/me returns it as subtitle.
  • Avatar: POST <prefix>/api/v1/account/avatar takes one multipart part named file_data. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to cabana.AvatarMaxBytes (2 MiB) and within http.body_limits.upload_bytes; anything else answers 422 on avatar, 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/avatar removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in system_files with attachment type Backend\Models\User (cabana.BackendUserMorphType) and field avatar (cabana.BackendUserAvatarField), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. GET .../auth/me returns its url and a 250x250 crop thumb_url, and the top bar shows it in place of the initials.

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 application binary has two commands for operators:

./bin/acme admin:create --email admin@example.com --superuser
./bin/acme admin:reset-password admin@example.com

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:

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 token issued before the reset. Passwords are hashed with bcrypt at admin.password.bcrypt_cost, so hashes copied from a WinterCMS database keep working.