feat: User Groups admin screen with the users count and privileged codes protected

- controller golem15.user.usergroups behind golem15.users.access_groups
- list with users_count from the list's own query; form with the checkbox
  permission editor stored in user_groups.permissions
- creating a group with a privileged code, changing a code to or from one and
  deleting a privileged group need golem15.users.manage_privileged_groups
- a group delete removes its users_groups rows in the same transaction
- UserGroup gains Fillable, Rules and the read-only UsersCount; PermissionSet.Text
- TestAdminGroups and the D-06 matrix in TestAdminPrivilegedGroups
This commit is contained in:
Jakub Zych
2026-10-05 13:52:26 +02:00
parent 9611708792
commit a3992879db
15 changed files with 987 additions and 1 deletions

View File

@@ -13,6 +13,7 @@ import (
// directory also holds files that are not admin assets.
//
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml controllers/users/_hint.htm models/user/fields.yaml models/user/columns.yaml
//go:embed controllers/usergroups/config_list.yaml controllers/usergroups/config_form.yaml models/usergroup/fields.yaml models/usergroup/columns.yaml
var adminFS embed.FS
func (p *Plugin) AdminFS() fs.FS { return adminFS }

405
admin_groups_test.go Normal file
View File

@@ -0,0 +1,405 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
)
const (
// adminGroupsPath is the User Groups controller under the admin API.
adminGroupsPath = "/golem15/user/usergroups"
permAccessGroups = "golem15.users.access_groups"
privilegedGroupCodeForbidden = "You do not have permission to create, re-code or delete a privileged group. Nothing was saved."
)
// groupPath is the admin API path of one user group.
func groupPath(id uint) string {
return adminAPI(adminGroupsPath) + "/" + strconv.FormatUint(uint64(id), 10)
}
// storedGroup is a user_groups row as the database holds it.
type storedGroup struct {
Name string
Code *string
Description *string
Permissions *string
}
// group reads a user_groups row; nil when it is gone.
func (e *adminEnv) group(t *testing.T, id uint) *storedGroup {
t.Helper()
var rows []storedGroup
if err := e.gdb.Raw(`SELECT name, code, description, permissions FROM user_groups WHERE id = ?`, id).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return nil
}
return &rows[0]
}
// sideMenu returns the side item codes of the navigation item `user` as the
// administrator sees them; nil when the item is not shown.
func (e *adminEnv) sideMenu(t *testing.T, token string) []string {
t.Helper()
var nav []struct {
Code string `json:"code"`
SideMenu []struct {
Code string `json:"code"`
Controller string `json:"controller"`
} `json:"sideMenu"`
}
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav)
for _, item := range nav {
if item.Code != "user" {
continue
}
codes := []string{}
for _, side := range item.SideMenu {
codes = append(codes, side.Code+"="+side.Controller)
}
return codes
}
return nil
}
// TestAdminGroups covers the User Groups screen (D-06 ordinary path, D-15,
// D-16, D-24, D-29): its permission, the list with the member count, the
// form with the code rules and the checkbox permission editor, and the
// delete with its pivot cleanup.
func TestAdminGroups(t *testing.T) {
env := newAdminEnv(t)
usersOnly := env.admin(t, permAccessUsers)
token := env.admin(t, permAccessGroups)
stamp := adminStamp()
// The screen needs golem15.users.access_groups: the users permission
// alone opens nothing here and does not show the side item.
for _, path := range []string{adminAPI(adminGroupsPath), adminAPI(adminGroupsPath + "/schema/list"), adminAPI(adminGroupsPath + "/schema/form")} {
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, usersOnly, nil)
if code := adminDecode(t, rec).Error.Code; code != "forbidden" {
t.Fatalf("%s code = %q", path, code)
}
}
env.expect(t, http.StatusForbidden, http.MethodPost, adminAPI(adminGroupsPath), usersOnly, map[string]any{"name": "Nope " + stamp, "code": "nope-" + stamp})
if got := env.sideMenu(t, usersOnly); !reflect.DeepEqual(got, []string{"users=golem15.user.users"}) {
t.Fatalf("side menu with the users permission = %v", got)
}
if got := env.sideMenu(t, token); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
t.Fatalf("side menu with the groups permission = %v", got)
}
// The users screen stays closed to the groups permission.
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath), token, nil)
// The list schema: Name, Code, Created and Users; rows open the update
// form; no checkboxes, no bulk actions, no delete in the toolbar.
var schema struct {
RecordURL string `json:"recordUrl"`
ShowCheckboxes bool `json:"showCheckboxes"`
Columns []struct {
Key string `json:"key"`
Label string `json:"label"`
Invisible bool `json:"invisible"`
Sortable bool `json:"sortable"`
Type string `json:"type"`
} `json:"columns"`
ToolbarButtons []string `json:"toolbarButtons"`
BulkActions []json.RawMessage `json:"bulkActions"`
Messages map[string]map[string]string `json:"messages"`
}
rawSchema := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath+"/schema/list"), token, nil)
adminInto(t, rawSchema, &schema)
var visible []string
for _, column := range schema.Columns {
if column.Invisible {
continue
}
visible = append(visible, column.Key+":"+column.Label)
if column.Key == "users_count" && column.Sortable {
t.Fatal("users_count is sortable")
}
}
if want := []string{"name:Name", "code:Code", "created_at:Created", "users_count:Users"}; !reflect.DeepEqual(visible, want) {
t.Fatalf("visible columns = %v, want %v", visible, want)
}
if schema.RecordURL != "golem15/user/usergroups/update/:id" || schema.ShowCheckboxes || len(schema.BulkActions) != 0 ||
!reflect.DeepEqual(schema.ToolbarButtons, []string{"create"}) {
t.Fatalf("list schema = %s", rawSchema.Body.String())
}
if got := schema.Messages["empty"]["other"]; got != "No user groups yet. Create the first one with New Group." {
t.Fatalf("empty message = %q", got)
}
if got := schema.Messages["create"]["other"]; got != "New Group" {
t.Fatalf("create message = %q", got)
}
// Without checkboxes there is no bulk delete on this screen.
if rec := env.call(t, http.MethodPost, adminAPI(adminGroupsPath+"/bulk-delete"), token, map[string]any{"ids": []uint{1}}); rec.Code == http.StatusOK {
t.Fatalf("bulk delete of groups = %d %s", rec.Code, rec.Body.String())
}
// The list carries the member count from its own query: 0 for a group
// without members (a number, not an absent value), the count otherwise.
empty := env.seedGroup(t, "Empty "+stamp, "empty-"+stamp)
pair := env.seedGroup(t, "Pair "+stamp, "pair-"+stamp)
single := env.seedGroup(t, "Single "+stamp, "single-"+stamp)
alice := env.seedUser(t, "alice", nil)
bob := env.seedUser(t, "bob", nil)
env.join(t, alice, pair, single)
env.join(t, bob, pair)
list := func(query string) (map[uint]map[string]any, int) {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath)+"?"+query, token, nil)
var rows []map[string]any
answer := adminInto(t, rec, &rows)
var meta struct {
Total int `json:"total"`
}
if err := json.Unmarshal(answer.Meta, &meta); err != nil {
t.Fatal(err)
}
out := map[uint]map[string]any{}
for _, row := range rows {
out[uint(row["id"].(float64))] = row
}
return out, meta.Total
}
rows, total := list("search=" + stamp)
if total != 3 || len(rows) != 3 {
t.Fatalf("list total = %d rows = %d", total, len(rows))
}
for id, want := range map[uint]float64{empty.ID: 0, pair.ID: 2, single.ID: 1} {
got, ok := rows[id]["users_count"].(float64)
if !ok || got != want {
t.Fatalf("users_count of %d = %v, want %v", id, rows[id]["users_count"], want)
}
}
if rows[pair.ID]["name"] != "Pair "+stamp || rows[pair.ID]["code"] != "pair-"+stamp {
t.Fatalf("list row = %v", rows[pair.ID])
}
// Paging counts groups, not memberships.
if _, total = list("search=" + stamp + "&per_page=20&page=1&sort=name&dir=asc"); total != 3 {
t.Fatalf("sorted list total = %d", total)
}
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminGroupsPath)+"?sort=users_count", token, nil)
// The form: name, code (preset from name), description and the checkbox
// permission editor on the Permissions tab.
read, write := "wiki."+stamp+".read", "wiki."+stamp+".write"
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Read', ?), (?, 'Write', ?)`,
read, "Wiki "+stamp, write, "Wiki "+stamp).Error; err != nil {
t.Fatal(err)
}
var form struct {
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
Mode string `json:"mode"`
Tab string `json:"tab"`
Preset *struct {
Field string `json:"field"`
} `json:"preset"`
Options []struct {
Code string `json:"code"`
} `json:"permissionOptions"`
} `json:"fields"`
Preview json.RawMessage `json:"preview"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath+"/schema/form"), token, nil), &form)
var names []string
for _, field := range form.Fields {
names = append(names, field.Name+":"+field.Type)
switch field.Name {
case "code":
if field.Preset == nil || field.Preset.Field != "name" {
t.Fatalf("code preset = %+v", field.Preset)
}
case "permissions":
offered := 0
for _, option := range field.Options {
if option.Code == read || option.Code == write {
offered++
}
}
if field.Mode != "checkbox" || field.Tab != "Permissions" || offered != 2 {
t.Fatalf("permissions field = %+v", field)
}
}
}
if want := []string{"name:text", "code:text", "description:textarea", "permissions:permissioneditor"}; !reflect.DeepEqual(names, want) {
t.Fatalf("form fields = %v, want %v", names, want)
}
if len(form.Preview) != 0 && string(form.Preview) != "null" {
t.Fatalf("the group form has a preview: %s", form.Preview)
}
// Create: the code needs the PHP format and must be unique; the name
// needs three characters.
create := func(status int, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return env.expect(t, status, http.MethodPost, adminAPI(adminGroupsPath), token, body)
}
code := "editors-" + stamp
for field, body := range map[string]map[string]any{
"code": {"name": "Editors " + stamp, "code": "bad code " + stamp},
"name": {"name": "ab", "code": "short-" + stamp},
} {
rec := create(http.StatusUnprocessableEntity, body)
if details := adminDecode(t, rec).Error.Details; len(details[field]) == 0 {
t.Fatalf("create refusal has no detail on %s: %s", field, rec.Body.String())
}
}
for _, bad := range []string{"dot." + stamp, "ąę" + stamp, "semi;" + stamp} {
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Editors " + stamp, "code": bad})
if details := adminDecode(t, rec).Error.Details; len(details["code"]) != 1 || details["code"][0] != "The code may only contain letters, numbers, dashes and underscores." {
t.Fatalf("code refusal for %q = %s", bad, rec.Body.String())
}
}
create(http.StatusUnprocessableEntity, map[string]any{"name": "No code " + stamp})
var before int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE name LIKE ?`, "%"+stamp).Scan(&before).Error; err != nil || before != 3 {
t.Fatalf("refused creates left rows: %d (%v)", before, err)
}
id := createdID(t, create(http.StatusCreated, map[string]any{"name": "Editors " + stamp, "code": code, "description": "May edit"}))
if got := env.group(t, id); got == nil || got.Name != "Editors "+stamp || got.Code == nil || *got.Code != code ||
got.Description == nil || *got.Description != "May edit" || got.Permissions != nil {
t.Fatalf("created group = %+v", got)
}
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Editors again " + stamp, "code": code})
if details := adminDecode(t, rec).Error.Details; len(details["code"]) == 0 {
t.Fatalf("duplicate code refusal = %s", rec.Body.String())
}
// Update: name and description; the group's own code sent back is not a
// duplicate; a bad code is refused and changes nothing.
update := func(status int, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return env.expect(t, status, http.MethodPut, groupPath(id), token, body)
}
update(http.StatusOK, map[string]any{"name": "Editors renamed " + stamp, "code": code, "description": "Edits the wiki"})
if got := env.group(t, id); got.Name != "Editors renamed "+stamp || *got.Code != code || *got.Description != "Edits the wiki" {
t.Fatalf("updated group = %+v", got)
}
update(http.StatusUnprocessableEntity, map[string]any{"name": "Broken " + stamp, "code": "bad code"})
update(http.StatusUnprocessableEntity, map[string]any{"name": "Broken " + stamp, "code": "pair-" + stamp})
if got := env.group(t, id); got.Name != "Editors renamed "+stamp || *got.Code != code {
t.Fatalf("a refused update changed the group: %+v", got)
}
// An ordinary code changes freely.
code = "wiki-editors-" + stamp
update(http.StatusOK, map[string]any{"name": "Editors renamed " + stamp, "code": code})
if got := env.group(t, id); *got.Code != code {
t.Fatalf("code after the change = %q", *got.Code)
}
base := map[string]any{"name": "Editors renamed " + stamp, "code": code}
save := func(status int, permissions any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{"permissions": permissions}
for key, value := range base {
body[key] = value
}
return update(status, body)
}
storedPermissions := func() map[string]int {
t.Helper()
got := env.group(t, id)
if got.Permissions == nil {
return nil
}
var decoded map[string]int
if err := json.Unmarshal([]byte(*got.Permissions), &decoded); err != nil {
t.Fatalf("stored permissions %q: %v", *got.Permissions, err)
}
return decoded
}
// Checkbox mode stores a JSON object of code to 1.
save(http.StatusOK, map[string]int{read: 1, write: 1})
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1, write: 1}) {
t.Fatalf("stored permissions = %v", got)
}
var record struct {
Permissions map[string]int `json:"permissions"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, groupPath(id), token, nil), &record)
if !reflect.DeepEqual(record.Permissions, map[string]int{read: 1, write: 1}) {
t.Fatalf("record permissions = %v", record.Permissions)
}
// A deny has no place in checkbox mode; neither has an unknown code.
for _, bad := range []any{map[string]int{read: -1}, map[string]int{read: 2}, map[string]int{"unknown." + stamp: 1}, "all"} {
rec := save(http.StatusUnprocessableEntity, bad)
if details := adminDecode(t, rec).Error.Details; len(details["permissions"]) == 0 {
t.Fatalf("permission refusal of %v = %s", bad, rec.Body.String())
}
}
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1, write: 1}) {
t.Fatalf("a refused save changed the permissions: %v", got)
}
// A save without the field leaves them alone; unchecking removes the
// entry; an empty object stores NULL.
update(http.StatusOK, base)
if got := storedPermissions(); len(got) != 2 {
t.Fatalf("a save without the field changed the permissions: %v", got)
}
save(http.StatusOK, map[string]int{read: 1, write: 0})
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1}) {
t.Fatalf("permissions after unchecking = %v", got)
}
save(http.StatusOK, map[string]int{})
if got := env.group(t, id); got.Permissions != nil {
t.Fatalf("cleared permissions = %q", *got.Permissions)
}
// A stored code that is no longer offered survives an edit, and a member
// of the group holds what the editor stored.
legacy := "legacy." + stamp
if err := env.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, `{"`+legacy+`":1}`, id).Error; err != nil {
t.Fatal(err)
}
save(http.StatusOK, map[string]int{write: 1})
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{legacy: 1, write: 1}) {
t.Fatalf("permissions after an edit over a legacy code = %v", got)
}
// The screen cannot write a membership or the count.
created := &models.UserGroup{ID: id}
env.join(t, alice, created)
update(http.StatusOK, map[string]any{"name": base["name"], "code": code, "users_count": 99, "users": []uint{bob.ID}, "Users": []uint{bob.ID}})
if got := env.groupIDs(t, bob.ID); !reflect.DeepEqual(got, sortedIDs(pair)) {
t.Fatalf("a group save changed bob's memberships: %v", got)
}
if rows, _ = list("search=" + stamp); rows[id]["users_count"].(float64) != 1 {
t.Fatalf("users_count after a save that sent one = %v", rows[id]["users_count"])
}
// Delete (D-29): the standard form delete removes the group and its
// memberships in one transaction; the members and their other
// memberships stay.
rec = env.expect(t, http.StatusOK, http.MethodDelete, groupPath(pair.ID), token, nil)
if env.group(t, pair.ID) != nil {
t.Fatalf("the group survived its delete: %s", rec.Body.String())
}
var left int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users_groups WHERE user_group_id = ?`, pair.ID).Scan(&left).Error; err != nil || left != 0 {
t.Fatalf("memberships of the deleted group = %d (%v)", left, err)
}
if env.reload(t, alice.ID) == nil || env.reload(t, bob.ID) == nil {
t.Fatal("a group delete removed a member")
}
if got := env.groupIDs(t, alice.ID); !reflect.DeepEqual(got, sortedIDs(single, created)) {
t.Fatalf("alice's memberships after the delete = %v", got)
}
if got := env.groupIDs(t, bob.ID); len(got) != 0 {
t.Fatalf("bob's memberships after the delete = %v", got)
}
env.expect(t, http.StatusNotFound, http.MethodGet, groupPath(pair.ID), token, nil)
if rows, total = list("search=" + stamp); total != 3 || rows[pair.ID] != nil {
t.Fatalf("list after the delete: total %d", total)
}
}

View File

@@ -25,6 +25,13 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Permissions: []string{controllers.PermissionAccessUsers},
Controller: "golem15.user.users",
},
{
Code: "usergroups",
Label: "golem15.user::lang.groups.all_groups",
Icon: "users",
Permissions: []string{controllers.PermissionAccessGroups},
Controller: "golem15.user.usergroups",
},
},
},
}

View File

@@ -421,4 +421,136 @@ func TestAdminPrivilegedGroups(t *testing.T) {
t.Fatalf("group %s is locked although no code is privileged", strconv.FormatUint(uint64(id), 10))
}
}
// D-06: the group's code carries the privilege, so creating a group with
// a privileged code, changing a code to or from one, and deleting a
// privileged group need the extra permission on the User Groups screen.
// The harness database is shared and may hold several groups coded
// admin, so the matrix runs on two codes of its own next to admin.
priv, second := "priv-"+stamp, "priv2-"+stamp
coded := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", priv, second}); err != nil {
t.Fatal(err)
}
})
groupsOnly := coded.admin(t, permAccessGroups)
groupsManager := coded.admin(t, permAccessGroups, permManagePrivileged)
privGroup := coded.seedGroup(t, "Privileged "+stamp, priv)
plainCode := "plain-" + stamp
plainGroup := coded.seedGroup(t, "Plain "+stamp, plainCode)
holder := coded.seedUser(t, "holder", nil)
coded.join(t, holder, privGroup, plainGroup)
holds := func(code string) bool {
t.Helper()
held, err := classes.HasGroupCode(t.Context(), coded.gdb, holder.ID, code)
if err != nil {
t.Fatal(err)
}
return held
}
refusedCode := func(rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String())
}
answer := adminDecode(t, rec)
if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupCodeForbidden {
t.Fatalf("refusal = %s", rec.Body.String())
}
if got := answer.Error.Details["code"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupCodeForbidden {
t.Fatalf("refusal details = %v", answer.Error.Details)
}
}
sameGroup := func(id uint, name, code string) {
t.Helper()
got := coded.group(t, id)
if got == nil || got.Name != name || got.Code == nil || *got.Code != code {
t.Fatalf("group %d = %+v, want %s (%s)", id, got, name, code)
}
}
codeCount := func(code string) int64 {
t.Helper()
var n int64
if err := coded.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE code = ?`, code).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
groupPut := func(token string, id uint, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return coded.call(t, http.MethodPut, groupPath(id), token, body)
}
// Without the permission each of the four is refused and changes nothing.
refusedCode(coded.call(t, http.MethodPost, adminAPI(adminGroupsPath), groupsOnly, map[string]any{"name": "Second " + stamp, "code": second}))
if codeCount(second) != 0 {
t.Fatal("a refused create left a privileged group")
}
refusedCode(groupPut(groupsOnly, plainGroup.ID, map[string]any{"name": "Promoted " + stamp, "code": second}))
sameGroup(plainGroup.ID, "Plain "+stamp, plainCode)
refusedCode(groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Demoted " + stamp, "code": "demoted-" + stamp}))
sameGroup(privGroup.ID, "Privileged "+stamp, priv)
// From one privileged code to another is a change of a privileged code.
refusedCode(groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Privileged " + stamp, "code": second}))
sameGroup(privGroup.ID, "Privileged "+stamp, priv)
refusedCode(coded.call(t, http.MethodDelete, groupPath(privGroup.ID), groupsOnly, nil))
sameGroup(privGroup.ID, "Privileged "+stamp, priv)
pivot(coded, holder, privGroup, plainGroup)
if !holds(priv) || holds(second) {
t.Fatal("a refused group change moved the privilege")
}
// The default code admin is protected the same way, and a group without
// a code cannot be given a privileged one.
refusedCode(groupPut(groupsOnly, adminGroup.ID, map[string]any{"name": "Admins " + stamp, "code": "was-admin-" + stamp}))
refusedCode(coded.call(t, http.MethodDelete, groupPath(adminGroup.ID), groupsOnly, nil))
sameGroup(adminGroup.ID, "Admins "+stamp, "admin")
refusedCode(groupPut(groupsOnly, uncoded.ID, map[string]any{"name": "Uncoded " + stamp, "code": second}))
if got := coded.group(t, uncoded.ID); got == nil || got.Code != nil {
t.Fatalf("the uncoded group after a refused save = %+v", got)
}
// Everything else on a privileged group needs only the groups
// permission: its name, description and frontend permissions, with the
// code sent back as it is.
if rec := groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Privileged renamed " + stamp, "code": priv, "description": "Kept code", "permissions": map[string]int{}}); rec.Code != http.StatusOK {
t.Fatalf("name-only update of a privileged group = %d %s", rec.Code, rec.Body.String())
}
sameGroup(privGroup.ID, "Privileged renamed "+stamp, priv)
// Ordinary groups, a case variant of a privileged code included, are
// re-coded and deleted freely.
if rec := groupPut(groupsOnly, plainGroup.ID, map[string]any{"name": "Plain " + stamp, "code": "plain2-" + stamp}); rec.Code != http.StatusOK {
t.Fatalf("ordinary re-code = %d %s", rec.Code, rec.Body.String())
}
plainCode = "plain2-" + stamp
if rec := groupPut(groupsOnly, uncoded.ID, map[string]any{"name": "Uncoded " + stamp, "code": "coded-" + stamp}); rec.Code != http.StatusOK {
t.Fatalf("first code of an uncoded group = %d %s", rec.Code, rec.Body.String())
}
if rec := coded.call(t, http.MethodDelete, groupPath(upper.ID), groupsOnly, nil); rec.Code != http.StatusOK || coded.group(t, upper.ID) != nil {
t.Fatalf("delete of the ADMIN group = %d %s", rec.Code, rec.Body.String())
}
// With the permission all four succeed.
created := coded.call(t, http.MethodPost, adminAPI(adminGroupsPath), groupsManager, map[string]any{"name": "Second " + stamp, "code": second})
if created.Code != http.StatusCreated || codeCount(second) != 1 {
t.Fatalf("manager create of a privileged group = %d %s", created.Code, created.Body.String())
}
if rec := groupPut(groupsManager, privGroup.ID, map[string]any{"name": "Privileged renamed " + stamp, "code": "freed-" + stamp}); rec.Code != http.StatusOK {
t.Fatalf("manager re-code from a privileged code = %d %s", rec.Code, rec.Body.String())
}
if holds(priv) {
t.Fatal("the holder kept the privilege after the group lost its code")
}
if rec := groupPut(groupsManager, plainGroup.ID, map[string]any{"name": "Plain " + stamp, "code": priv}); rec.Code != http.StatusOK {
t.Fatalf("manager re-code to a privileged code = %d %s", rec.Code, rec.Body.String())
}
if !holds(priv) {
t.Fatal("the holder did not gain the privilege with the group's new code")
}
if rec := coded.call(t, http.MethodDelete, groupPath(plainGroup.ID), groupsManager, nil); rec.Code != http.StatusOK || coded.group(t, plainGroup.ID) != nil {
t.Fatalf("manager delete of a privileged group = %d %s", rec.Code, rec.Body.String())
}
pivot(coded, holder, privGroup)
if holds(priv) || coded.reload(t, holder.ID) == nil {
t.Fatal("the delete of the privileged group left the privilege or removed the member")
}
}

View File

@@ -12,6 +12,7 @@ import (
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
usersAdminController{app: app},
usergroupsAdminController{app: appAccess(app)},
}
}
@@ -36,4 +37,14 @@ func init() {
var _ cabana.FieldRelationProvider = usersAdminController{}
var _ cabana.RelationLockProvider = usersAdminController{}
var _ cabana.PermissionEditorProvider = usersAdminController{}
var _ pact.AdminController = usergroupsAdminController{}
var _ pact.AdminPermissioned = usergroupsAdminController{}
var _ pact.AdminRecordSource = usergroupsAdminController{}
var _ pact.ListExtendQuery = usergroupsAdminController{}
var _ pact.FormBeforeCreate = usergroupsAdminController{}
var _ pact.FormBeforeUpdate = usergroupsAdminController{}
var _ pact.FormBeforeDelete = usergroupsAdminController{}
var _ pact.FormAfterDelete = usergroupsAdminController{}
var _ cabana.PermissionEditorProvider = usergroupsAdminController{}
}

47
controllers/app_access.go Normal file
View File

@@ -0,0 +1,47 @@
package controllers
import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// appAccess resolves the booted application on every call, so an admin
// controller never keeps a database handle or a config of its own.
type appAccess func() *backpack.App
// application returns the booted app, or nil before boot.
func (a appAccess) application() *backpack.App {
if a == nil {
return nil
}
return a()
}
// db is the application pool, or nil when the app has none.
func (a appAccess) db() *gorm.DB {
app := a.application()
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok {
return nil
}
return gdb
}
// config is the application config, or nil before boot.
func (a appAccess) config() *compass.Config {
app := a.application()
if app == nil {
return nil
}
return app.Config
}
// translator is the application translator, or nil when none is published.
func (a appAccess) translator() *phrasebook.Translator {
return appTranslator(a.application())
}

View File

@@ -0,0 +1,19 @@
name: golem15.user::lang.group.label
form: ~/plugins/golem15/user/models/usergroup/fields.yaml
modelClass: Golem15\User\Models\UserGroup
defaultRedirect: golem15/user/usergroups
create:
redirect: golem15/user/usergroups/update/:id
redirectClose: golem15/user/usergroups
update:
redirect: golem15/user/usergroups
redirectClose: golem15/user/usergroups
messages:
create: golem15.user::lang.groups.new_group
update: backend::lang.messages.form.update
saved: backend::lang.messages.form.saved
deleteConfirm: golem15.user::lang.groups.delete_confirm
deleted: backend::lang.messages.form.deleted

View File

@@ -0,0 +1,17 @@
list: ~/plugins/golem15/user/models/usergroup/columns.yaml
modelClass: Golem15\User\Models\UserGroup
title: golem15.user::lang.groups.list_title
recordUrl: golem15/user/usergroups/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
showSetup: true
showSorting: true
toolbar:
buttons: [create]
search:
prompt: backend::lang.list.search_prompt
messages:
create: golem15.user::lang.groups.new_group
empty: golem15.user::lang.groups.list_empty

View File

@@ -0,0 +1,246 @@
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
}

View File

@@ -78,6 +78,22 @@ user:
permissions_tab: Permissions
group:
label: Group
id: ID
name: Name
code: Code
code_comment: Enter a unique code used to identify this group.
code_invalid: The code may only contain letters, numbers, dashes and underscores.
description_field: Description
created_at: Created
users_count: Users
groups:
all_groups: User Groups
menu_label: Groups
list_title: Manage Groups
new_group: New Group
list_empty: No user groups yet. Create the first one with New Group.
delete_confirm: Do you really want to delete this group?
privileged_forbidden: You do not have permission to create, re-code or delete a privileged group. Nothing was saved.
organisation:
label: Organisation
empty_organisation: — No organisation —

View File

@@ -78,6 +78,22 @@ user:
permissions_tab: Uprawnienia
group:
label: Grupa
id: ID
name: Nazwa
code: Kod
code_comment: Wpisz unikalny kod służący do identyfikacji tej grupy.
code_invalid: Kod może zawierać tylko litery, cyfry, myślniki i podkreślenia.
description_field: Opis
created_at: Utworzona
users_count: Użytkownicy
groups:
all_groups: Grupy użytkowników
menu_label: Grupy
list_title: Zarządzaj grupami
new_group: Nowa grupa
list_empty: Brak grup użytkowników. Utwórz pierwszą przyciskiem Nowa grupa.
delete_confirm: Czy na pewno chcesz usunąć tę grupę?
privileged_forbidden: Nie masz uprawnień do tworzenia, zmiany kodu ani usuwania grup uprzywilejowanych. Nic nie zostało zapisane.
organisation:
label: Organizacja
empty_organisation: — Brak organizacji —

View File

@@ -92,6 +92,18 @@ func permissionInt(value any) (int, bool) {
return n, true
}
// Text returns the set as the text of a permission column that is a plain
// nullable string on its model (user_groups.permissions): the JSON object
// Value writes, or nil for a set without a non-zero value.
func (p PermissionSet) Text() *string {
value, err := p.Value()
text, ok := value.(string)
if err != nil || !ok {
return nil
}
return &text
}
// Value implements driver.Valuer: NULL for a set without a non-zero value,
// else a JSON object of integers with the zero values left out.
func (p PermissionSet) Value() (driver.Value, error) {

View File

@@ -4,7 +4,8 @@ import "time"
// UserGroup is a row of user_groups, the PHP UserGroup model. Membership
// lives in the users_groups pivot (User.Groups). Permissions is the raw
// jsonable text column.
// jsonable text column; ParsePermissionSet reads it and PermissionSet.Text
// writes it.
type UserGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
@@ -13,6 +14,11 @@ type UserGroup struct {
Permissions *string `gorm:"column:permissions"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
// UsersCount is the number of members, the PHP users_count relation. It
// is not a column of user_groups: only a query that selects an expression
// named users_count fills it (the admin list does), it is never written,
// and it is zero everywhere else.
UsersCount int64 `gorm:"column:users_count;->" json:"-"`
}
func (UserGroup) TableName() string { return "user_groups" }
@@ -24,6 +30,21 @@ const (
GroupRegistered = "registered"
)
// Fillable is the field list an admin save may set. Permissions is written
// through the permission editor, not by a fill.
func (UserGroup) Fillable() []string {
return []string{"name", "code", "description"}
}
// Rules is the PHP UserGroup rule set without its regex on code, which the
// rule engine does not know: the admin controller checks the code's format.
func (UserGroup) Rules() map[string]string {
return map[string]string{
"name": "required|between:3,64",
"code": "required|unique:user_groups",
}
}
func init() {
Register(UserGroup{})
}

View File

@@ -0,0 +1,15 @@
columns:
id:
label: golem15.user::lang.group.id
invisible: true
name:
label: golem15.user::lang.group.name
searchable: true
code:
label: golem15.user::lang.group.code
created_at:
label: golem15.user::lang.group.created_at
type: datetime
users_count:
label: golem15.user::lang.group.users_count
sortable: false

View File

@@ -0,0 +1,21 @@
fields:
name:
label: golem15.user::lang.group.name
type: text
span: left
required: true
code:
label: golem15.user::lang.group.code
comment: golem15.user::lang.group.code_comment
type: text
span: right
preset: name
description:
label: golem15.user::lang.group.description_field
type: textarea
size: tiny
permissions:
type: permissioneditor
mode: checkbox
span: full
tab: golem15.user::lang.user.permissions_tab