Files
summercms/modules/cabana/backend_roles_admin.go
Jakub Zych 9f14cf603c feat(19.7-01): account contributions, prompted record actions and backend subject events
- pact.HasAccountContributions compiled at Activate; GET /account/contributions
  and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
  (AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
  Prompt; the record action route accepts payload only for such an action
  (trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
  Value through CRUDService.RecordActionWithValue; a prompt without a label
  fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
  saves that changed role, permissions or superuser, Roles saves that changed
  grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
  TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
  OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
  README, docs/backend/{admin-controllers,users-and-permissions}.md
2026-10-22 16:31:29 +02:00

291 lines
9.7 KiB
Go

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"
// 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{}
_ 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
}
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}
}