- users form partial on the Administrators tab of the role update form, served by rolesAdmin.PartialData through scopeAdministrators (D-03) - deleted and deactivated holders marked, superusers only for superuser editors, first 100 rows with a count note, preview links for editors holding backend.manage_users - en/pl phrases, README and docs, TestRolesSmoke asserts the partial
407 lines
14 KiB
Go
407 lines
14 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"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"
|
|
|
|
// permissionLockedByCodeKey is the note of a role's code-declared grant.
|
|
const permissionLockedByCodeKey = "backend::lang.permissioneditor.locked_by_code"
|
|
|
|
// 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.FormAfterUpdate = rolesAdmin{}
|
|
_ pact.FormBeforeDelete = rolesAdmin{}
|
|
_ pact.AdminPartialData = rolesAdmin{}
|
|
_ PermissionEditorProvider = rolesAdmin{}
|
|
_ PermissionRecordLockProvider = 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
|
|
}
|
|
|
|
// db is the application pool, or nil without an application or pool.
|
|
func (c rolesAdmin) db() *gorm.DB {
|
|
app := c.screens.application()
|
|
if app == nil {
|
|
return nil
|
|
}
|
|
gdb, ok := app.Lookup[*gorm.DB]()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return gdb
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// AdminPermissionRecordLocks locks, on a role that code-declared grants
|
|
// (pact.Permission.Roles) attach to, every such grant, shown granted with
|
|
// the locked_by_code note (D-16): the grant comes from the plugin, so
|
|
// unticking it here would change nothing. Codes follow the catalog order.
|
|
func (c rolesAdmin) AdminPermissionRecordLocks(_ context.Context, field string, record any) ([]PermissionRecordLock, error) {
|
|
role, ok := record.(*BackendUserRole)
|
|
if field != rolesPermissionsField || !ok || role == nil {
|
|
return nil, fmt.Errorf("cabana: roles: cannot read permission field %q", field)
|
|
}
|
|
reg := c.registry()
|
|
if reg == nil {
|
|
return nil, nil
|
|
}
|
|
grants := reg.rolePermissions(role.Code)
|
|
if len(grants) == 0 {
|
|
return nil, nil
|
|
}
|
|
var out []PermissionRecordLock
|
|
for _, code := range reg.permissionOrder {
|
|
if grants[code] {
|
|
out = append(out, PermissionRecordLock{Code: code, Value: 1, Note: permissionLockedByCodeKey})
|
|
}
|
|
}
|
|
return out, 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
|
|
}
|
|
if !role.priorLoaded {
|
|
role.priorPermissions = role.Permissions
|
|
role.priorLoaded = true
|
|
}
|
|
role.Permissions = encoded
|
|
return nil
|
|
}
|
|
|
|
// FormAfterUpdate fires pact.MCPSubjectChanged (permissions) for every
|
|
// administrator holding the role when its grants changed, their ids read
|
|
// through the save's transaction. The save itself is unchanged.
|
|
func (c rolesAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
|
role, err := asRole(model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !role.priorLoaded || role.priorPermissions == role.Permissions {
|
|
return nil
|
|
}
|
|
db, err := writeDB(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var holders []uint
|
|
if err := db.Unscoped().Model(&BackendUser{}).Where("role_id = ?", role.ID).Pluck("id", &holders).Error; err != nil {
|
|
return err
|
|
}
|
|
fireSubjectChanged(ctx, c.screens.application(), pact.MCPSubjectPermissions, holders...)
|
|
return nil
|
|
}
|
|
|
|
// refuseCodeDeclaredCode refuses a submitted code that code-declared grants
|
|
// (pact.Permission.Roles) attach to when it is not the stored code (empty
|
|
// on create). A role never becomes a code-declared system role through this
|
|
// screen, including when the editor is a superuser (D-16).
|
|
func (c rolesAdmin) refuseCodeDeclaredCode(submitted, stored string) error {
|
|
if submitted == stored {
|
|
return nil
|
|
}
|
|
if len(c.registry().rolePermissions(submitted)) > 0 {
|
|
return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_locked"}}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormBeforeCreate refuses a code another role uses and a code that
|
|
// code-declared grants attach to. A role created here is never a system
|
|
// role: is_system is not fillable, and a code-declared code is refused.
|
|
func (c rolesAdmin) FormBeforeCreate(ctx context.Context, model any) error {
|
|
role, err := asRole(model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.refuseCodeDeclaredCode(role.Code, ""); 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"}}}
|
|
}
|
|
if err := c.refuseCodeDeclaredCode(role.Code, stored.Code); err != nil {
|
|
return err
|
|
}
|
|
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}
|
|
}
|
|
|
|
// roleHoldersLimit caps the rows of the update form's holders list; past it
|
|
// the list ends with a note naming the total.
|
|
const roleHoldersLimit = 100
|
|
|
|
// roleHoldersView is the curated view model of the users partial: the
|
|
// role's holders as the editor may see them (D-03), whether the role has
|
|
// none, and the translated note when more than roleHoldersLimit hold it.
|
|
// The zero value renders nothing (the create form has no record).
|
|
type roleHoldersView struct {
|
|
Rows []roleHolderRow
|
|
Empty bool
|
|
Note string
|
|
}
|
|
|
|
// roleHolderRow is one holder: plain strings only, the Administrators
|
|
// preview link when the editor may manage administrators, and the phrase
|
|
// keys of its states (Superuser, Deleted, Deactivated).
|
|
type roleHolderRow struct {
|
|
Login string
|
|
FirstName string
|
|
LastName string
|
|
Email string
|
|
Link string
|
|
States []string
|
|
}
|
|
|
|
// PartialData serves the update form's users partial, the read-only list of
|
|
// the administrators holding the role (WinterCMS UserRoles' _users). The
|
|
// query and the total go through scopeAdministrators, so deleted holders
|
|
// are listed, matching the delete refusal's count (D-17), and an editor who
|
|
// does not act as a superuser never sees a superuser holder, neither as a
|
|
// row nor in the total (D-03). Roles are assigned only on the
|
|
// Administrators form; this list changes nothing.
|
|
func (c rolesAdmin) PartialData(ctx context.Context, name string, record any) (any, error) {
|
|
if name != "users" {
|
|
return nil, fmt.Errorf("cabana: roles: unknown partial %q", name)
|
|
}
|
|
role, ok := record.(*BackendUserRole)
|
|
if !ok || role == nil || role.ID == 0 {
|
|
return roleHoldersView{}, nil
|
|
}
|
|
db := c.db()
|
|
if db == nil {
|
|
return nil, errors.New("cabana: roles: the holders list needs the database")
|
|
}
|
|
base := scopeAdministrators(ctx, db.WithContext(ctx).Model(&BackendUser{})).
|
|
Where("backend_users.role_id = ?", role.ID)
|
|
var total int64
|
|
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
view := roleHoldersView{Empty: total == 0}
|
|
if total == 0 {
|
|
return view, nil
|
|
}
|
|
var users []BackendUser
|
|
if err := base.Session(&gorm.Session{}).
|
|
Select("backend_users.id", "backend_users.login", "backend_users.first_name", "backend_users.last_name",
|
|
"backend_users.email", "backend_users.is_activated", "backend_users.is_superuser", "backend_users.deleted_at").
|
|
Order("backend_users.login").Order("backend_users.id").
|
|
Limit(roleHoldersLimit).Find(&users).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
linkBase := ""
|
|
if Allows(principal, []string{PermissionManageUsers}) {
|
|
prefix, err := AdminPrefix(c.screens.application())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
linkBase = prefix + "/" + strings.ReplaceAll(AdministratorsControllerID, ".", "/") + "/"
|
|
}
|
|
view.Rows = make([]roleHolderRow, 0, len(users))
|
|
for _, user := range users {
|
|
row := roleHolderRow{Login: user.Login, FirstName: user.FirstName, LastName: user.LastName, Email: user.Email}
|
|
if linkBase != "" {
|
|
row.Link = linkBase + strconv.FormatUint(uint64(user.ID), 10) + "/preview"
|
|
}
|
|
if user.IsSuperuser {
|
|
row.States = append(row.States, "backend::lang.user.superuser")
|
|
}
|
|
if user.DeletedAt.Valid {
|
|
row.States = append(row.States, "backend::lang.user.state_deleted")
|
|
}
|
|
if !user.IsActivated {
|
|
row.States = append(row.States, "backend::lang.user.state_deactivated")
|
|
}
|
|
view.Rows = append(view.Rows, row)
|
|
}
|
|
if total > roleHoldersLimit {
|
|
key := rolesLang + "users_more"
|
|
params := map[string]string{"shown": strconv.Itoa(roleHoldersLimit), "count": strconv.FormatInt(total, 10)}
|
|
view.Note = key
|
|
if tr := c.translator(); tr != nil {
|
|
view.Note = tr.Choice(towel.WithLocale(ctx, schemaLocale(ctx, tr)), key, total, params)
|
|
}
|
|
}
|
|
return view, nil
|
|
}
|