- 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
425 lines
18 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|