feat(19.6.1-01): Roles screen

- Settings -> System -> Roles (summercms.cabana.roles, backend.manage_roles,
  order 110): name, code slugged from the name, description, checkbox
  permission editor with the editor's ceiling
- system roles (is_system or code-declared grants) keep their code and
  cannot be deleted; a role any admin holds (deleted ones included) cannot
  be deleted, the refusal names the role and the count
- role codes unique among roles
This commit is contained in:
Jakub Zych
2026-10-22 11:18:30 +02:00
parent 530b02af9c
commit 653307de0c
9 changed files with 392 additions and 2 deletions

View File

@@ -53,6 +53,7 @@ Use cases:
- Tag lists: a `type: taglist` field in `fields.yaml` edits a list of tags (WinterCMS TagList). `mode: array` binds a JSON column (a type with `sql.Scanner` and `driver.Valuer`, such as `lagoon.Jsonable[[]string]`) and travels as a JSON array of strings; `mode: string`, the default, binds a string column. `options` is a YAML list of suggested tags; `separator` (`comma` or `space`, string mode only) and `customTags` are refused on other types, and an unknown mode or separator, options that are not a list, the money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset` or a column of the wrong Go type fails boot naming the field. A save trims every tag, drops empty ones and keeps a repeated tag once; in array mode null or an empty list stores `[]`, in string mode the tags are split on the separator and stored joined by `,` or a single space (null stores an empty string). A value of the wrong shape is a 422 on the field with `backend::lang.taglist.invalid`, and with `customTags: false` a tag outside `options` is a 422 with `backend::lang.taglist.not_allowed` (`:tag`), on controller forms, settings forms and relation forms alike; a taglist in a repeater row fails boot. The record GET serves the stored list (array mode) or string (string mode). The schema serves `mode`, `options`, `separator` and `customTags` on the field; the admin edits the tags as chips.
- 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, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call 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. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server.
- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack.
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
@@ -231,7 +232,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| Identifier | Description |
|------------|-------------|
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation, permissions and record action extensions, plus the admin's own Administrators screen, and returns the admin `cabana.Routes`, or nil when no plugin registers a controller. |
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation, permissions and record action extensions, plus the admin's own Administrators and Roles screens, and returns the admin `cabana.Routes`, or nil when no plugin registers a controller. |
| `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`, `admin:reset-password` and `admin:token:*` commands. |

View File

@@ -0,0 +1,211 @@
package cabana
import (
"context"
"errors"
"fmt"
"strconv"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// rolesLang prefixes the phrase keys of the Roles screen.
const rolesLang = "backend::lang.user.role."
// rolesPermissionsField is the Roles form's permission editor field.
const rolesPermissionsField = "permissions"
// rolesAdmin is the Roles screen (summercms.cabana.roles), the WinterCMS
// Backend\Controllers\UserRoles controller over backend_user_roles. Every
// route needs backend.manage_roles.
type rolesAdmin struct {
screens *backendScreens
}
var (
_ pact.AdminController = rolesAdmin{}
_ pact.AdminPermissioned = rolesAdmin{}
_ pact.AdminRecordSource = rolesAdmin{}
_ pact.FormRecordTitle = rolesAdmin{}
_ pact.FormBeforeCreate = rolesAdmin{}
_ pact.FormBeforeUpdate = rolesAdmin{}
_ pact.FormBeforeDelete = rolesAdmin{}
_ PermissionEditorProvider = rolesAdmin{}
)
func (rolesAdmin) ID() string { return RolesControllerID }
func (rolesAdmin) ModelName() string { return `Backend\Models\UserRole` }
func (rolesAdmin) ConfigDir() string { return "controllers/roles" }
func (rolesAdmin) RequiredPermissions() []string { return []string{PermissionManageRoles} }
func (rolesAdmin) NewRecord() any { return &BackendUserRole{} }
func (c rolesAdmin) registry() *Registry { return c.screens.registry() }
// translator is the application's translator, or nil.
func (c rolesAdmin) translator() *phrasebook.Translator {
app := c.screens.application()
if app == nil {
return nil
}
tr, _ := app.Lookup[*phrasebook.Translator]()
return tr
}
func asRole(model any) (*BackendUserRole, error) {
role, ok := model.(*BackendUserRole)
if !ok || role == nil {
return nil, errors.New("cabana: roles: record is not a backend role")
}
return role, nil
}
// FormRecordTitle names a role by its name.
func (rolesAdmin) FormRecordTitle(_ context.Context, _ *gorm.DB, record any) (string, error) {
role, err := asRole(record)
if err != nil {
return "", err
}
return role.Name, nil
}
// systemRole reports whether a role with this flag and code is a system
// role: is_system, or a code that code-declared grants (pact.Permission
// Roles) attach to. Its code cannot change and it cannot be deleted.
func (c rolesAdmin) systemRole(isSystem bool, code string) bool {
return isSystem || len(c.registry().rolePermissions(code)) > 0
}
// writeDB is the save's transaction as a fresh session.
func writeDB(ctx context.Context) (*gorm.DB, error) {
tx, ok := TxFromContext(ctx)
if !ok {
return nil, errors.New("cabana: roles: a save needs its transaction")
}
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx), nil
}
// AdminPermissionOptions offers the whole permission catalog as checkboxes;
// a code the editor does not hold is locked (D-07), so a non-superuser can
// neither add nor remove it on any role.
func (c rolesAdmin) AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error) {
if field != rolesPermissionsField {
return nil, fmt.Errorf("cabana: roles: unknown permission field %q", field)
}
return catalogOptions(ctx, c.registry()), nil
}
// AdminPermissionValues decodes the role's stored grants (value 1).
func (rolesAdmin) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
role, ok := record.(*BackendUserRole)
if field != rolesPermissionsField || !ok || role == nil {
return nil, fmt.Errorf("cabana: roles: cannot read permission field %q", field)
}
return decodePermissionValues(role.Permissions, false), nil
}
// AdminSetPermissionValues stores the grants on the role; the save writes
// the row.
func (rolesAdmin) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
role, ok := record.(*BackendUserRole)
if field != rolesPermissionsField || !ok || role == nil {
return fmt.Errorf("cabana: roles: cannot write permission field %q", field)
}
encoded, err := encodePermissionValues(values)
if err != nil {
return err
}
role.Permissions = encoded
return nil
}
// FormBeforeCreate refuses a code another role uses. A role created here is
// never a system role: is_system is not fillable.
func (c rolesAdmin) FormBeforeCreate(ctx context.Context, model any) error {
role, err := asRole(model)
if err != nil {
return err
}
return c.checkCode(ctx, role)
}
// FormBeforeUpdate refuses a change of a system role's code (D-16) and a
// code another role uses. The code is fillable, so the stored code is read
// again through the save's transaction.
func (c rolesAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
role, err := asRole(model)
if err != nil {
return err
}
db, err := writeDB(ctx)
if err != nil {
return err
}
var stored BackendUserRole
if err := db.Select("id", "code", "is_system").Where("id = ?", role.ID).Take(&stored).Error; err != nil {
return err
}
if c.systemRole(stored.IsSystem, stored.Code) && role.Code != stored.Code {
return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_locked"}}}
}
return c.checkCode(ctx, role)
}
// checkCode refuses a code another role already uses (exact match): the
// database has only an index on code, and code lookups fail on an
// ambiguous one.
func (rolesAdmin) checkCode(ctx context.Context, role *BackendUserRole) error {
db, err := writeDB(ctx)
if err != nil {
return err
}
var taken int64
if err := db.Model(&BackendUserRole{}).Where("code = ? AND id <> ?", role.Code, role.ID).Count(&taken).Error; err != nil {
return err
}
if taken > 0 {
return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_taken"}}}
}
return nil
}
// FormBeforeDelete refuses deleting a system role and a role any
// administrator holds, deleted administrators included: they still
// reference it (D-17). The message names the role and the count, already
// translated in the request locale, because a refusal carries no
// placeholders. Roles have no deleted_at, so the delete that follows is a
// hard delete of an unused role.
func (c rolesAdmin) FormBeforeDelete(ctx context.Context, model any) error {
role, err := asRole(model)
if err != nil {
return err
}
if c.systemRole(role.IsSystem, role.Code) {
return &ForbiddenError{Message: rolesLang + "delete_system"}
}
db, err := writeDB(ctx)
if err != nil {
return err
}
var holders int64
if err := db.Unscoped().Model(&BackendUser{}).Where("role_id = ?", role.ID).Count(&holders).Error; err != nil {
return err
}
if holders == 0 {
return nil
}
tr := c.translator()
key := rolesLang + "delete_has_users"
params := map[string]string{"name": role.Name, "count": strconv.FormatInt(holders, 10)}
message := key
if tr != nil {
message = tr.Choice(towel.WithLocale(ctx, schemaLocale(ctx, tr)), key, holders, params)
}
return &ForbiddenError{Message: message}
}

View File

@@ -74,7 +74,7 @@ func (*backendScreens) AdminFS() fs.FS {
}
func (s *backendScreens) AdminControllers() []pact.AdminController {
return []pact.AdminController{usersAdmin{screens: s}}
return []pact.AdminController{usersAdmin{screens: s}, rolesAdmin{screens: s}}
}
// Permissions declares the two screen permissions with no role grants
@@ -100,6 +100,16 @@ func (*backendScreens) Settings() []pact.SettingsItem {
Permissions: []string{PermissionManageUsers},
Controller: AdministratorsControllerID,
},
{
Code: "roles",
Label: "backend::lang.user.role.menu_label",
Description: "backend::lang.user.role.menu_description",
Category: "backend::lang.settings.category_system",
Icon: "shield-check",
Order: 110,
Permissions: []string{PermissionManageRoles},
Controller: RolesControllerID,
},
}
}

View File

@@ -467,3 +467,73 @@ func TestAdministratorsLifecycleSmoke(t *testing.T) {
t.Fatalf("activated target = %+v", got)
}
}
const rolesPath = "/summercms/cabana/roles"
func TestRolesSmoke(t *testing.T) {
env := newScreensEnv(t)
_, rootToken := env.admin(t, "roles-root", true, "")
holder, _ := env.admin(t, "roles-holder", false, "")
name := "Editors " + env.stamp
created := env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, rootToken, map[string]any{
"name": name, "code": "editors-" + env.stamp, "description": "Edit things",
"permissions": map[string]any{"acme.demo.access": 1},
})
data, _ := created["data"].(map[string]any)
roleID := uint(data["id"].(float64))
var stored cabana.BackendUserRole
if err := env.gdb.First(&stored, roleID).Error; err != nil {
t.Fatal(err)
}
if stored.Permissions != `{"acme.demo.access":1}` || stored.IsSystem {
t.Fatalf("created role = %+v", stored)
}
// A code another role uses is refused on the field.
details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, rootToken,
map[string]any{"name": "Other " + env.stamp, "code": "editors-" + env.stamp}, "")
if !strings.Contains(fmt.Sprint(details["code"]), "already used") {
t.Fatalf("taken code details = %v", details)
}
// Assign it through the Administrators form, then the role cannot go.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken,
map[string]any{"login": holder.Login, "email": holder.Email, "role": roleID})
if got := env.storedUser(t, holder.ID); got.RoleID == nil || *got.RoleID != roleID {
t.Fatalf("assigned role = %v", got.RoleID)
}
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil,
"“"+name+"” is assigned to 1 administrator.")
var developer cabana.BackendUserRole
if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil {
t.Fatal(err)
}
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, nil,
"System roles cannot be deleted.")
details = env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken,
map[string]any{"name": developer.Name, "code": "developer-renamed"}, "")
if !strings.Contains(fmt.Sprint(details["code"]), "cannot be changed") {
t.Fatalf("system code details = %v", details)
}
// Name and description of a system role stay editable.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken,
map[string]any{"name": developer.Name, "code": developer.Code, "description": developer.Description})
settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil)
var codes []string
entries, _ := settings["data"].([]any)
for _, entry := range entries {
item, _ := entry.(map[string]any)
if code, _ := item["code"].(string); code == "administrators" || code == "roles" {
codes = append(codes, code)
}
}
if fmt.Sprint(codes) != "[administrators roles]" {
t.Fatalf("superuser settings order = %v", codes)
}
// An unused role is deleted.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken,
map[string]any{"login": holder.Login, "email": holder.Email, "role": developer.ID})
env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil)
}

View File

@@ -0,0 +1,19 @@
name: backend::lang.user.role.name
form: ~/plugins/summercms/cabana/models/userrole/fields.yaml
modelClass: Backend\Models\UserRole
defaultRedirect: summercms/cabana/roles
create:
redirect: summercms/cabana/roles/update/:id
redirectClose: summercms/cabana/roles
update:
redirect: summercms/cabana/roles/update/:id
redirectClose: summercms/cabana/roles
messages:
create: backend::lang.user.role.new
update: backend::lang.user.role.edit
saved: backend::lang.messages.form.saved
deleteConfirm: backend::lang.user.role.delete_confirm
deleted: backend::lang.messages.form.deleted

View File

@@ -0,0 +1,19 @@
list: ~/plugins/summercms/cabana/models/userrole/columns.yaml
modelClass: Backend\Models\UserRole
title: backend::lang.user.role.list_title
recordUrl: summercms/cabana/roles/update/:id
noRecordsMessage: backend::lang.user.role.list_empty
recordsPerPage: 20
showCheckboxes: true
showSetup: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
create: backend::lang.user.role.new
empty: backend::lang.user.role.list_empty
deleteConfirm: backend::lang.user.role.delete_selected_confirm
deleted: backend::lang.messages.list.deleted

View File

@@ -0,0 +1,26 @@
columns:
name:
label: backend::lang.user.role.name_field
searchable: true
sortable: true
code:
label: backend::lang.user.role.code_field
searchable: true
sortable: true
description:
label: backend::lang.user.role.description_field
searchable: true
is_system:
label: backend::lang.user.role.is_system
type: switch
sortable: true
created_at:
label: backend::lang.user.created_at
type: datetime
invisible: true
sortable: true
updated_at:
label: backend::lang.list.column_updated
type: datetime
invisible: true
sortable: true

View File

@@ -0,0 +1,26 @@
fields:
name:
label: backend::lang.user.role.name_field
comment: backend::lang.user.role.name_comment
type: text
span: left
code:
label: backend::lang.user.role.code_field
comment: backend::lang.user.role.code_comment
type: text
span: right
preset:
type: slug
field: name
description:
label: backend::lang.user.role.description_field
type: textarea
size: tiny
span: full
permissions:
label: backend::lang.user.role.permissions_field
comment: backend::lang.user.role.permissions_comment
type: permissioneditor
mode: checkbox
span: full
tab: backend::lang.user.permissions