Files
sm-user-plugin/controllers/usergroups_admin_controller.go
Jakub Zych dbb3d33b48 fix: user groups delete keeps FormAfterDelete's declaration; members' subject events fire from FormBeforeDelete
The member ids are read in the delete's transaction before the memberships
go, and the permissions events fire after it commits, as before; the
smoke now covers a group delete.
2026-10-23 12:16:53 +02:00

296 lines
11 KiB
Go

package controllers
import (
"context"
"errors"
"fmt"
"regexp"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// PermissionAccessGroups is the backend permission of the User Groups
// screen, the PHP UserGroups::$requiredPermissions.
const PermissionAccessGroups = "golem15.users.access_groups"
// groupsLang prefixes the phrase keys of the User Groups screen.
const groupsLang = "golem15.user::lang.groups."
// groupCodePattern is the PHP UserGroup code rule
// regex:/^[a-zA-Z0-9_\-]+$/. lagoon.Validate has no regex token, so the
// Form before-hooks check it.
var groupCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// usergroupsAdminController is the Winter UserGroups backend controller: the
// list with the member count and the form with the frontend permissions of
// the group. The screen and every write need golem15.users.access_groups.
//
// A group's code decides whether its members are privileged
// (golem15.user.privileged_groups), so three writes additionally need
// golem15.users.manage_privileged_groups: creating a group with a privileged
// code, changing a code to or from a privileged one, and deleting a
// privileged group. Everything else on a privileged group (its name,
// description and frontend permissions) needs only the screen's permission.
//
// The screen never writes a membership: users_groups changes only through
// the user form's groups field, and loses the rows of a deleted group.
type usergroupsAdminController struct {
app appAccess
}
func (usergroupsAdminController) ID() string { return "golem15.user.usergroups" }
func (usergroupsAdminController) ModelName() string { return `Golem15\User\Models\UserGroup` }
func (usergroupsAdminController) ConfigDir() string { return "controllers/usergroups" }
func (usergroupsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessGroups}
}
func (usergroupsAdminController) NewRecord() any { return &models.UserGroup{} }
// ListExtendQuery adds the member count of every group on the page, the PHP
// users_count relation column, in the list's own query. The two select
// arguments keep the list's count query a plain COUNT(*).
func (usergroupsAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
return db.Select("user_groups.*",
"(SELECT COUNT(*) FROM users_groups WHERE users_groups.user_group_id = user_groups.id) AS users_count")
}
// FormBeforeCreate checks the code's format and refuses a privileged code
// without golem15.users.manage_privileged_groups.
func (c usergroupsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
}
if err := c.requireCodeFormat(ctx, group); err != nil {
return err
}
if !classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
return nil
}
return c.requirePrivilegedPermission(ctx)
}
// FormBeforeUpdate checks the code's format and refuses a code change to or
// from a privileged code without golem15.users.manage_privileged_groups. The
// stored code is read through the write's transaction; a save that leaves
// the code as it is passes, whatever the code.
func (c usergroupsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
}
if err := c.requireCodeFormat(ctx, group); err != nil {
return err
}
db := requestDB(ctx, c.app.db)
if db == nil {
return errors.New("user groups admin: gorm handle is missing")
}
var stored []struct{ Code *string }
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Table("user_groups").Select("code").Where("id = ?", group.ID).Scan(&stored).Error
if err != nil {
return err
}
if len(stored) != 1 {
return errors.New("user groups admin: the group being updated was not found")
}
if sameCode(stored[0].Code, group.Code) {
return nil
}
codes := classes.PrivilegedGroupCodes(c.app.config())
if !classes.IsPrivilegedCode(codes, stored[0].Code) && !classes.IsPrivilegedCode(codes, group.Code) {
return nil
}
return c.requirePrivilegedPermission(ctx)
}
// FormBeforeDelete refuses the delete of a privileged group without
// golem15.users.manage_privileged_groups: it would take the privilege from
// every member. The record is the stored row, loaded in the delete's
// transaction. A delete that goes ahead reads the members there; each gets
// a pact.MCPSubjectChanged permissions event after the delete commits,
// since the group's grants are gone (FormAfterDelete removes the
// memberships).
func (c usergroupsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
}
if classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
if err := c.requirePrivilegedPermission(ctx); err != nil {
return err
}
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return nil
}
members, err := groupMemberIDs(ctx, tx, group.ID)
if err != nil {
return err
}
c.permissionsChanged(ctx, tx, members)
return nil
}
// FormAfterDelete removes the memberships of the deleted group in the same
// transaction: users_groups has no foreign key that would do it. The member
// users stay.
func (usergroupsAdminController) FormAfterDelete(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("user groups admin: a delete needs its transaction")
}
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error
}
// FormAfterUpdate tells the MCP endpoint that the members of the group may
// do something else now (the form saves the group's permissions): one
// pact.MCPSubjectChanged permissions event per member, the members read in
// the save's transaction and the events fired after it commits.
func (c usergroupsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("user groups admin: an update needs its transaction")
}
members, err := groupMemberIDs(ctx, tx, group.ID)
if err != nil {
return err
}
c.permissionsChanged(ctx, tx, members)
return nil
}
// permissionsChanged fires one permissions subject event per user after tx
// commits.
func (c usergroupsAdminController) permissionsChanged(ctx context.Context, tx *gorm.DB, userIDs []uint) {
if len(userIDs) == 0 {
return
}
app := c.app.application()
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
for _, id := range userIDs {
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, id, pact.MCPSubjectPermissions)
}
})
}
// groupMemberIDs reads the ids of the group's members through tx.
func groupMemberIDs(ctx context.Context, tx *gorm.DB, groupID uint) ([]uint, error) {
ids := []uint{}
err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Model(&models.UsersGroup{}).Where("user_group_id = ?", groupID).Order("user_id").Pluck("user_id", &ids).Error
return ids, err
}
// requirePrivilegedPermission answers nil when the administrator on ctx
// holds golem15.users.manage_privileged_groups, else the 403 of the screen.
// A context without a principal is refused.
func (usergroupsAdminController) requirePrivilegedPermission(ctx context.Context) error {
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
return nil
}
const message = groupsLang + "privileged_forbidden"
return &cabana.ForbiddenError{Message: message, Details: map[string]any{"code": []string{message}}}
}
// requireCodeFormat answers 422 on code when it holds anything but letters,
// digits, underscore and hyphen. An empty code is left to the required rule.
func (c usergroupsAdminController) requireCodeFormat(ctx context.Context, group *models.UserGroup) error {
if group.Code == nil || *group.Code == "" || groupCodePattern.MatchString(*group.Code) {
return nil
}
message := "The code may only contain letters, numbers, dashes and underscores."
if tr := c.app.translator(); tr != nil {
const key = "golem15.user::lang.group.code_invalid"
if text := tr.Get(ctx, key, nil); text != "" && text != key {
message = text
}
}
return &cabana.ValidationError{Details: map[string]any{"code": []string{message}}}
}
// sameCode compares two nullable group codes, case-sensitively.
func sameCode(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
// AdminPermissionOptions offers the rows of
// golem15_user_frontend_permissions, by tab and code, then the frontend
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
// None is locked.
func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if field != permissionsField {
return nil, fmt.Errorf("user groups admin: unknown permission field %q", field)
}
db := requestDB(ctx, c.app.db)
if db == nil {
return nil, errors.New("user groups admin: gorm handle is missing")
}
return classes.FrontendPermissionCatalog(ctx, c.app.application(), db)
}
// AdminPermissionValues returns the group's stored permissions, read with
// the tolerance of models.ParsePermissionSet.
func (usergroupsAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
group, ok := record.(*models.UserGroup)
if field != permissionsField || !ok || group == nil {
return nil, fmt.Errorf("user groups admin: cannot read permission field %q", field)
}
if group.Permissions == nil {
return map[string]int{}, nil
}
set, err := models.ParsePermissionSet([]byte(*group.Permissions))
if err != nil {
return nil, err
}
values := make(map[string]int, len(set))
for code, value := range set {
values[code] = value
}
return values, nil
}
// AdminSetPermissionValues writes the validated set back as the JSON object
// PHP stores (checkbox mode: 1 per allowed code), or NULL for an empty set.
// The save writes the row.
func (usergroupsAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
group, ok := record.(*models.UserGroup)
if field != permissionsField || !ok || group == nil {
return fmt.Errorf("user groups admin: cannot write permission field %q", field)
}
set := make(models.PermissionSet, len(values))
for code, value := range values {
set[code] = value
}
group.Permissions = set.Text()
return nil
}