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} }