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:
211
modules/cabana/backend_roles_admin.go
Normal file
211
modules/cabana/backend_roles_admin.go
Normal 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}
|
||||
}
|
||||
Reference in New Issue
Block a user