diff --git a/admin.go b/admin.go index 56f862e..a24df59 100644 --- a/admin.go +++ b/admin.go @@ -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 } diff --git a/admin_groups_test.go b/admin_groups_test.go new file mode 100644 index 0000000..ce4a1c5 --- /dev/null +++ b/admin_groups_test.go @@ -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) + } +} diff --git a/admin_navigation.go b/admin_navigation.go index e0d23b9..e5fbc3b 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -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", + }, }, }, } diff --git a/admin_privileged_test.go b/admin_privileged_test.go index 50ceab4..02d4648 100644 --- a/admin_privileged_test.go +++ b/admin_privileged_test.go @@ -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") + } } diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 75fbfcf..282d6ac 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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{} } diff --git a/controllers/app_access.go b/controllers/app_access.go new file mode 100644 index 0000000..28babf1 --- /dev/null +++ b/controllers/app_access.go @@ -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()) +} diff --git a/controllers/usergroups/config_form.yaml b/controllers/usergroups/config_form.yaml new file mode 100644 index 0000000..8115b10 --- /dev/null +++ b/controllers/usergroups/config_form.yaml @@ -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 diff --git a/controllers/usergroups/config_list.yaml b/controllers/usergroups/config_list.yaml new file mode 100644 index 0000000..fabbd7c --- /dev/null +++ b/controllers/usergroups/config_list.yaml @@ -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 diff --git a/controllers/usergroups_admin_controller.go b/controllers/usergroups_admin_controller.go new file mode 100644 index 0000000..70432e9 --- /dev/null +++ b/controllers/usergroups_admin_controller.go @@ -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 +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 52ff2b6..2a2fd64 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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 — diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 7fd5248..cfead3a 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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 — diff --git a/models/permission_set.go b/models/permission_set.go index bc32e0d..88f18b5 100644 --- a/models/permission_set.go +++ b/models/permission_set.go @@ -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) { diff --git a/models/user_group.go b/models/user_group.go index 5a837ff..a6dd59d 100644 --- a/models/user_group.go +++ b/models/user_group.go @@ -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{}) } diff --git a/models/usergroup/columns.yaml b/models/usergroup/columns.yaml new file mode 100644 index 0000000..568ca46 --- /dev/null +++ b/models/usergroup/columns.yaml @@ -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 diff --git a/models/usergroup/fields.yaml b/models/usergroup/fields.yaml new file mode 100644 index 0000000..9e2bf36 --- /dev/null +++ b/models/usergroup/fields.yaml @@ -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