Files
sm-user-plugin/admin_privileged_test.go
Jakub Zych 9611708792 feat: manage a user's groups from the user form, privileged groups locked
- groups relation field on the user form, the only writer of users_groups
- UsersGroup pivot model
- AdminRelationLocks: adding or removing a group whose code is in
  golem15.user.privileged_groups needs golem15.users.manage_privileged_groups,
  on create and on update; refused saves answer 403 and write nothing
- TestAdminUserGroupsField and TestAdminPrivilegedGroups
2026-10-05 13:44:15 +02:00

425 lines
18 KiB
Go

package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
const privilegedGroupForbidden = "You do not have permission to change membership of privileged groups. Nothing was saved."
// groupOptions returns the choices of the user form's groups field that
// match search, as id to locked.
func (e *adminEnv) groupOptions(t *testing.T, token, search string) map[uint]bool {
t.Helper()
var options []struct {
Value uint `json:"value"`
Locked bool `json:"locked"`
}
path := adminAPI(adminUsersPath + "/fields/groups/options?per_page=100&search=" + search)
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &options)
out := map[uint]bool{}
for _, option := range options {
out[option.Value] = option.Locked
}
return out
}
// recordGroups reads a user through the admin API and returns the ids of
// the groups field and, per id, whether its label is flagged locked.
func (e *adminEnv) recordGroups(t *testing.T, token string, userID uint) ([]uint, map[uint]bool) {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(userID, ""), token, nil)
var data struct {
Groups []uint `json:"groups"`
}
answer := adminInto(t, rec, &data)
var meta struct {
Labels map[string][]struct {
Value uint `json:"value"`
Locked bool `json:"locked"`
} `json:"labels"`
}
if err := json.Unmarshal(answer.Meta, &meta); err != nil {
t.Fatalf("record meta %q: %v", string(answer.Meta), err)
}
locked := map[uint]bool{}
for _, label := range meta.Labels["groups"] {
locked[label.Value] = label.Locked
}
return data.Groups, locked
}
// sortedIDs returns the ids of the groups in ascending order, the order
// groupIDs reads the pivot in.
func sortedIDs(groups ...*models.UserGroup) []uint {
ids := make([]uint, 0, len(groups))
for _, group := range groups {
ids = append(ids, group.ID)
}
for i := 1; i < len(ids); i++ {
for j := i; j > 0 && ids[j-1] > ids[j]; j-- {
ids[j-1], ids[j] = ids[j], ids[j-1]
}
}
return ids
}
// TestAdminUserGroupsField covers the groups field of the user form with
// ordinary groups: it is the writer of users_groups, it replaces the user's
// memberships only when the field is sent, it refuses ids that are not
// groups, and none of it reaches a user API payload (D-08).
func TestAdminUserGroupsField(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stamp := adminStamp()
first := env.seedGroup(t, "First "+stamp, "first-"+stamp)
second := env.seedGroup(t, "Second "+stamp, "second-"+stamp)
user := env.seedUser(t, "groups", nil)
base := map[string]any{"name": "groups", "email": user.Email}
save := func(status int, groups any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{"groups": groups}
for key, value := range base {
body[key] = value
}
return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body)
}
pivot := func(want ...*models.UserGroup) {
t.Helper()
if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) {
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...))
}
}
// The field is a multiple relation field on the Account tab, and its
// options are the user groups; none is locked for ordinary codes.
var form struct {
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
Multiple bool `json:"multiple"`
Tab string `json:"tab"`
} `json:"fields"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
found := false
for _, field := range form.Fields {
if field.Name != "groups" {
continue
}
found = true
if field.Type != "relation" || !field.Multiple || field.Tab != "Account" {
t.Fatalf("groups field = %+v", field)
}
}
if !found {
t.Fatal("the user form has no groups field")
}
if options := env.groupOptions(t, token, stamp); len(options) != 2 || options[first.ID] || options[second.ID] {
t.Fatalf("group options = %v", options)
}
// Setting the field writes exactly the chosen memberships.
saved := save(http.StatusOK, []uint{first.ID, second.ID})
pivot(first, second)
var record struct {
Groups []uint `json:"groups"`
}
adminInto(t, saved, &record)
if !reflect.DeepEqual(record.Groups, sortedIDs(first, second)) {
t.Fatalf("record groups = %v", record.Groups)
}
if ids, locked := env.recordGroups(t, token, user.ID); !reflect.DeepEqual(ids, sortedIDs(first, second)) || locked[first.ID] || locked[second.ID] || len(locked) != 2 {
t.Fatalf("shown groups = %v locked %v", ids, locked)
}
save(http.StatusOK, []uint{second.ID})
pivot(second)
// A save without the field leaves the memberships alone.
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "renamed", "email": user.Email})
pivot(second)
if got := env.reload(t, user.ID); got.Name == nil || *got.Name != "renamed" {
t.Fatalf("name after a save without groups = %v", got.Name)
}
// An id that is not a group, a duplicate and a value that is not a list
// are refused on the field and change nothing.
for _, bad := range []any{[]uint{second.ID, 987654321}, []uint{first.ID, first.ID}, first.ID, []string{"x"}} {
rec := save(http.StatusUnprocessableEntity, bad)
if details := adminDecode(t, rec).Error.Details; len(details["groups"]) == 0 {
t.Fatalf("refusal of %v has no detail on groups: %s", bad, rec.Body.String())
}
pivot(second)
}
// The plain relation name of the model is not a way around the field.
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{
"name": "renamed", "email": user.Email, "Groups": []map[string]any{{"id": first.ID}}, "users_groups": []uint{first.ID},
})
pivot(second)
// The user API payload still answers an empty list (D-08), although the
// user is a member of a group.
status, userToken := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK {
t.Fatalf("user login = %d", status)
}
fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil)
var body struct {
User map[string]any `json:"user"`
}
if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil || fetch.Code != http.StatusOK {
t.Fatalf("fetch = %d %s (%v)", fetch.Code, fetch.Body.String(), err)
}
if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 {
t.Fatalf("user payload groups = %v", body.User["groups"])
}
// Clearing the field removes every membership.
save(http.StatusOK, []uint{})
pivot()
// A create with the field writes the memberships of the new user.
const password = "groups-password-1"
created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
"name": "Created", "email": "groups-created-" + stamp + "@example.test",
"password": password, "password_confirmation": password, "send_invite": false,
"groups": []uint{first.ID},
})
if got := env.groupIDs(t, createdID(t, created)); !reflect.DeepEqual(got, []uint{first.ID}) {
t.Fatalf("users_groups of the created user = %v", got)
}
// A refused create leaves no user behind.
refusedEmail := "groups-refused-" + stamp + "@example.test"
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
"name": "Refused", "email": refusedEmail, "password": password, "password_confirmation": password, "send_invite": false,
"groups": []uint{987654321},
})
var refused int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&refused).Error; err != nil || refused != 0 {
t.Fatalf("a refused create left %d users (%v)", refused, err)
}
}
// TestAdminPrivilegedGroups covers threat T-12-18 as revisited by D-04 to
// D-07: golem15.users.access_users changes ordinary memberships, and adding
// or removing a privileged group additionally needs
// golem15.users.manage_privileged_groups, on create and on update. It also
// ties the field to the takeover guard (D-30).
func TestAdminPrivilegedGroups(t *testing.T) {
env := newAdminEnv(t)
limited := env.admin(t, permAccessUsers)
manager := env.admin(t, permAccessUsers, permManagePrivileged)
stamp := adminStamp()
// The migrations seed only guest and registered; the default privileged
// list is [admin].
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
ordinaryCode := "ordinary-" + stamp
ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode)
uncoded := env.seedGroup(t, "Uncoded "+stamp, "")
// A code that differs from a privileged one only by case is ordinary.
upper := env.seedGroup(t, "Upper "+stamp, "ADMIN")
isAdmin := func(e *adminEnv, userID uint) bool {
t.Helper()
held, err := classes.HasGroupCode(t.Context(), e.gdb, userID, "admin")
if err != nil {
t.Fatal(err)
}
return held
}
put := func(e *adminEnv, status int, token string, user *models.User, extra map[string]any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{"name": "kept", "email": user.Email}
for key, value := range extra {
body[key] = value
}
return e.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body)
}
assertRefused := func(rec *httptest.ResponseRecorder) {
t.Helper()
answer := adminDecode(t, rec)
if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupForbidden {
t.Fatalf("refusal = %s", rec.Body.String())
}
if got := answer.Error.Details["groups"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupForbidden {
t.Fatalf("refusal details = %v", answer.Error.Details)
}
}
pivot := func(e *adminEnv, user *models.User, want ...*models.UserGroup) {
t.Helper()
if got := e.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) {
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...))
}
}
unchanged := func(original *models.User) {
t.Helper()
got := env.reload(t, original.ID)
if got == nil || got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) ||
got.Password != original.Password || !reflect.DeepEqual(got.OrganisationID, original.OrganisationID) {
t.Fatalf("user %d changed: %+v", original.ID, got)
}
}
// Without the permission the privileged group is flagged locked among the
// options; a group without a code and one whose code differs by case are
// not. With the permission nothing is locked.
if options := env.groupOptions(t, limited, stamp); len(options) != 4 || !options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] {
t.Fatalf("options without the permission = %v", options)
}
if options := env.groupOptions(t, manager, stamp); len(options) != 4 || options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] {
t.Fatalf("options with the permission = %v", options)
}
// Adding the privileged group on update is refused as a whole: the name
// in the same body is not saved and the pivot is unchanged.
target := env.seedUser(t, "target", nil)
env.join(t, target, ordinary)
if isAdmin(env, target.ID) {
t.Fatal("the target starts as a site admin")
}
assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"name": "Promoted", "groups": []uint{ordinary.ID, adminGroup.ID}}))
pivot(env, target, ordinary)
unchanged(target)
if isAdmin(env, target.ID) {
t.Fatal("a refused save made a site admin")
}
// The privileged group alone, replacing the ordinary one, is refused too.
assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"groups": []uint{adminGroup.ID}}))
pivot(env, target, ordinary)
// Removing it from a member is refused: by leaving it out, and by
// clearing the field.
member := env.seedUser(t, "member", nil)
env.join(t, member, adminGroup, ordinary)
for _, groups := range [][]uint{{ordinary.ID}, {}} {
assertRefused(put(env, http.StatusForbidden, limited, member, map[string]any{"name": "Demoted", "groups": groups}))
pivot(env, member, adminGroup, ordinary)
unchanged(member)
if !isAdmin(env, member.ID) {
t.Fatal("a refused save removed a site admin")
}
}
// The member's privileged label is flagged locked for the limited admin
// and not for the manager.
if ids, locked := env.recordGroups(t, limited, member.ID); !reflect.DeepEqual(ids, sortedIDs(adminGroup, ordinary)) || !locked[adminGroup.ID] || locked[ordinary.ID] {
t.Fatalf("member groups for the limited admin = %v locked %v", ids, locked)
}
if _, locked := env.recordGroups(t, manager, member.ID); locked[adminGroup.ID] || locked[ordinary.ID] {
t.Fatalf("member groups for the manager are locked: %v", locked)
}
// Creating a user who is already in the privileged group is refused and
// leaves no user row.
const password = "privileged-password-1"
create := func(token, email string, groups []uint) *httptest.ResponseRecorder {
t.Helper()
return env.call(t, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
"name": "Created", "email": email, "password": password, "password_confirmation": password, "send_invite": false,
"groups": groups,
})
}
refusedEmail := "born-admin-" + stamp + "@example.test"
rec := create(limited, refusedEmail, []uint{adminGroup.ID, ordinary.ID})
if rec.Code != http.StatusForbidden {
t.Fatalf("create with a privileged group = %d %s", rec.Code, rec.Body.String())
}
assertRefused(rec)
var born int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&born).Error; err != nil || born != 0 {
t.Fatalf("a refused create left %d users (%v)", born, err)
}
// Ordinary changes need only the users permission, also on a member of
// the privileged group as long as that membership is sent back as it is;
// a group without a code and a case variant are ordinary.
put(env, http.StatusOK, limited, member, map[string]any{"groups": []uint{adminGroup.ID, uncoded.ID, upper.ID}})
pivot(env, member, adminGroup, uncoded, upper)
put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{uncoded.ID}})
pivot(env, target, uncoded)
put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{}})
pivot(env, target)
if isAdmin(env, target.ID) || !isAdmin(env, member.ID) {
t.Fatal("ordinary changes moved the site-admin membership")
}
// With the permission every refused request succeeds.
put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID, adminGroup.ID}})
pivot(env, target, ordinary, adminGroup)
if !isAdmin(env, target.ID) {
t.Fatal("the manager could not add the privileged group")
}
put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID}})
pivot(env, target, ordinary)
put(env, http.StatusOK, manager, member, map[string]any{"groups": []uint{}})
pivot(env, member)
if isAdmin(env, target.ID) || isAdmin(env, member.ID) {
t.Fatal("the manager could not remove the privileged group")
}
rec = create(manager, "born-admin-ok-"+stamp+"@example.test", []uint{adminGroup.ID})
if rec.Code != http.StatusCreated {
t.Fatalf("manager create with a privileged group = %d %s", rec.Code, rec.Body.String())
}
if bornID := createdID(t, rec); !isAdmin(env, bornID) {
t.Fatal("the manager's create did not write the privileged membership")
}
// D-30: the takeover guard follows the membership this field writes. No
// row is inserted into users_groups directly here.
guarded := env.seedUser(t, "guarded", nil)
newEmail := "guarded-new-" + stamp + "@example.test"
put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{adminGroup.ID}})
rec = env.expect(t, http.StatusForbidden, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail})
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 {
t.Fatalf("takeover refusal = %s", rec.Body.String())
}
if got := env.reload(t, guarded.ID); got.Email != guarded.Email {
t.Fatalf("a refused takeover changed the email to %q", got.Email)
}
put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{}})
env.expect(t, http.StatusOK, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail})
if got := env.reload(t, guarded.ID); got.Email != newEmail {
t.Fatalf("email after the membership was removed = %q", got.Email)
}
// The privileged list is config, read per request. With the ordinary
// group's code in it, that group is the locked one and admin is not.
moved := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil {
t.Fatal(err)
}
})
movedLimited := moved.admin(t, permAccessUsers)
if options := moved.groupOptions(t, movedLimited, stamp); len(options) != 4 || options[adminGroup.ID] || !options[ordinary.ID] || options[uncoded.ID] {
t.Fatalf("options under the moved list = %v", options)
}
put(moved, http.StatusOK, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID}})
pivot(moved, member, adminGroup)
assertRefused(put(moved, http.StatusForbidden, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID, ordinary.ID}}))
pivot(moved, member, adminGroup)
// target is in the ordinary group, which is now the privileged one.
assertRefused(put(moved, http.StatusForbidden, movedLimited, target, map[string]any{"groups": []uint{}}))
pivot(moved, target, ordinary)
// An empty list means no group is privileged: nothing is locked.
open := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{}); err != nil {
t.Fatal(err)
}
})
openLimited := open.admin(t, permAccessUsers)
for id, locked := range open.groupOptions(t, openLimited, stamp) {
if locked {
t.Fatalf("group %s is locked although no code is privileged", strconv.FormatUint(uint64(id), 10))
}
}
}