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" "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. 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) { return nil } return c.requirePrivilegedPermission(ctx) } // 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 } // 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. 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") } rows, err := classes.FrontendPermissionOptions(ctx, db) if err != nil { return nil, err } options := make([]cabana.PermissionOption, 0, len(rows)) for _, row := range rows { option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab} if row.Comment != nil { option.Comment = *row.Comment } options = append(options, option) } return options, nil } // 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 }