--- title: Users and permissions description: Sign administrators in with JWT and cookie auth, declare permissions and navigation, and manage administrators from the console. section: backend order: 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](../../modules/cabana/README.md) 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 /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 `. - 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](../../modules/bouncer/README.md) 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](../services/authentication.md) for tokens and guards in general. The admin keys go in `config/admin.yaml`, with the secret in the environment (`SUMMER_ADMIN__JWT__SECRET`): ```yaml 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`: ```go src=modules/cabana/example_controller_test.go#BlogPlugin.Permissions // 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"}, } } ``` ```go src=modules/cabana/example_controller_test.go#BlogPlugin.Navigation // 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` 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.*`. 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](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 `/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 `/account` from the user menu. It saves through `PUT /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. - **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 /api/v1/auth/me` returns it as `subtitle`. - **Avatar:** `POST /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 /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. - **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`, `365d` or `never`) and scopes from `GET /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](#api-tokens) for the wire contract and scopes. 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 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) ``` `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 ``` `--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 ` 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).