The removal check for the json tag of users.last_seen showed that the subtest read the user API payloads only, which are built field by field. It now marshals the model as well, so a tag that would expose an admin column fails it.
967 lines
48 KiB
Go
967 lines
48 KiB
Go
package user
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/compass"
|
|
)
|
|
|
|
// pivotSnapshot is the whole users_groups table as text, for byte-identical
|
|
// comparisons around a request.
|
|
func (e *adminEnv) pivotSnapshot(t *testing.T) string {
|
|
t.Helper()
|
|
var rows []struct{ UserID, UserGroupID uint }
|
|
if err := e.gdb.Raw(`SELECT user_id, user_group_id FROM users_groups ORDER BY user_id, user_group_id`).Scan(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var b strings.Builder
|
|
for _, row := range rows {
|
|
fmt.Fprintf(&b, "%d:%d;", row.UserID, row.UserGroupID)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// userPayload signs the user in and returns the token and the user payloads
|
|
// of login and fetch.
|
|
func (e *adminEnv) userPayloads(t *testing.T, email, password string) (string, map[string]map[string]any, map[string]string) {
|
|
t.Helper()
|
|
payloads, raws := map[string]map[string]any{}, map[string]string{}
|
|
login := e.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": email, "password": password})
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
User map[string]any `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(login.Body.Bytes(), &body); err != nil || body.Token == "" {
|
|
t.Fatalf("login body %s (%v)", login.Body.String(), err)
|
|
}
|
|
payloads["login"], raws["login"] = body.User, login.Body.String()
|
|
fetch := e.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", body.Token, nil)
|
|
var fetched struct {
|
|
User map[string]any `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(fetch.Body.Bytes(), &fetched); err != nil {
|
|
t.Fatalf("fetch body %s (%v)", fetch.Body.String(), err)
|
|
}
|
|
payloads["fetch"], raws["fetch"] = fetched.User, fetch.Body.String()
|
|
return body.Token, payloads, raws
|
|
}
|
|
|
|
// privilegedList boots an environment whose privileged group list is codes.
|
|
func privilegedList(t *testing.T, codes ...string) *adminEnv {
|
|
t.Helper()
|
|
return newAdminEnv(t, func(cfg *compass.Config) {
|
|
if err := cfg.Set("golem15.user.privileged_groups", codes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPhase121Threats has one subtest per mitigated plugin threat of Phase
|
|
// 12.1, named by its id. Each asserts the protection through the admin API or
|
|
// the user API of the booted plugin, so removing the protection fails the
|
|
// subtest on an assertion (scripts/check-phase12.1.sh --removal in the
|
|
// framework repository).
|
|
//
|
|
// T-12.1-26 and T-12.1-32 are accepted risks and T-12.1-33 is a publication
|
|
// rule; they have no subtest.
|
|
func TestPhase121Threats(t *testing.T) {
|
|
// T-12.1-18: every Users, User Groups and Organisations route, bulk action
|
|
// and record action needs its permission.
|
|
t.Run("T-12.1-18", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
stranger := env.admin(t)
|
|
usersOnly := env.admin(t, permAccessUsers)
|
|
groupsOnly := env.admin(t, permAccessGroups)
|
|
stamp := adminStamp()
|
|
user := env.seedUser(t, "gated", func(u *models.User) { u.IsActivated = false })
|
|
group := env.seedGroup(t, "Gated "+stamp, "gated-"+stamp)
|
|
organisation := env.seedOrganisation(t, "Gated "+stamp)
|
|
ids := map[string]any{"ids": []uint{user.ID}}
|
|
type route struct {
|
|
method, path string
|
|
body any
|
|
}
|
|
users := []route{
|
|
{http.MethodGet, adminAPI(adminUsersPath), nil},
|
|
{http.MethodGet, adminAPI(adminUsersPath + "/schema/list"), nil},
|
|
{http.MethodGet, adminAPI(adminUsersPath + "/schema/form"), nil},
|
|
{http.MethodGet, adminAPI(adminUsersPath + "/filters/groups/options"), nil},
|
|
{http.MethodGet, adminAPI(adminUsersPath + "/fields/groups/options"), nil},
|
|
{http.MethodGet, adminAPI(adminUsersPath + "/fields/organisation/options"), nil},
|
|
{http.MethodGet, adminAPI(adminUsersPath + "/partials/hint?id=" + strconv.FormatUint(uint64(user.ID), 10)), nil},
|
|
{http.MethodGet, userPath(user.ID, ""), nil},
|
|
{http.MethodPost, adminAPI(adminUsersPath), map[string]any{"name": "Made", "email": "made-" + stamp + "@example.test", "password": "made-password-1", "password_confirmation": "made-password-1"}},
|
|
{http.MethodPut, userPath(user.ID, ""), map[string]any{"name": "Changed", "email": user.Email}},
|
|
{http.MethodDelete, userPath(user.ID, ""), nil},
|
|
{http.MethodPost, adminAPI(adminUsersPath + "/bulk-delete"), ids},
|
|
}
|
|
for _, action := range []string{"activate", "deactivate", "restore", "ban", "unban"} {
|
|
users = append(users, route{http.MethodPost, adminAPI(adminUsersPath + "/bulk/" + action), ids})
|
|
}
|
|
for _, action := range []string{"activate", "unban", "unsuspend"} {
|
|
users = append(users, route{http.MethodPost, userPath(user.ID, "/actions/"+action), map[string]any{}})
|
|
}
|
|
groups := []route{
|
|
{http.MethodGet, adminAPI(adminGroupsPath), nil},
|
|
{http.MethodGet, adminAPI(adminGroupsPath + "/schema/list"), nil},
|
|
{http.MethodGet, adminAPI(adminGroupsPath + "/schema/form"), nil},
|
|
{http.MethodGet, groupPath(group.ID), nil},
|
|
{http.MethodPost, adminAPI(adminGroupsPath), map[string]any{"name": "Made " + stamp, "code": "made-" + stamp}},
|
|
{http.MethodPut, groupPath(group.ID), map[string]any{"name": "Changed " + stamp, "code": "gated-" + stamp}},
|
|
{http.MethodDelete, groupPath(group.ID), nil},
|
|
{http.MethodPost, adminAPI(adminGroupsPath + "/bulk-delete"), map[string]any{"ids": []uint{group.ID}}},
|
|
}
|
|
organisations := []route{
|
|
{http.MethodGet, adminAPI(adminOrganisationsPath), nil},
|
|
{http.MethodGet, adminAPI(adminOrganisationsPath + "/schema/list"), nil},
|
|
{http.MethodGet, adminAPI(adminOrganisationsPath + "/schema/form"), nil},
|
|
{http.MethodGet, adminAPI(adminOrganisationsPath + "/schema/relation/members"), nil},
|
|
{http.MethodGet, organisationPath(organisation.ID, ""), nil},
|
|
{http.MethodPost, adminAPI(adminOrganisationsPath), map[string]any{"name": "Made " + stamp}},
|
|
{http.MethodPut, organisationPath(organisation.ID, ""), map[string]any{"name": "Changed " + stamp, "slug": organisation.Slug}},
|
|
{http.MethodDelete, organisationPath(organisation.ID, ""), nil},
|
|
{http.MethodGet, organisationPath(organisation.ID, "/relations/members"), nil},
|
|
{http.MethodGet, organisationPath(organisation.ID, "/relations/members/candidates"), nil},
|
|
{http.MethodPost, organisationPath(organisation.ID, "/relations/members/link"), ids},
|
|
{http.MethodPost, organisationPath(organisation.ID, "/relations/members/unlink"), ids},
|
|
}
|
|
refused := func(screen, token string, routes []route) {
|
|
t.Helper()
|
|
for _, r := range routes {
|
|
rec := env.call(t, r.method, r.path, token, r.body)
|
|
if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Code != "forbidden" {
|
|
t.Fatalf("%s: %s %s without the permission = %d %s", screen, r.method, r.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
refused("users", stranger, users)
|
|
refused("users", groupsOnly, users)
|
|
refused("user groups", stranger, groups)
|
|
refused("user groups", usersOnly, groups)
|
|
refused("organisations", stranger, organisations)
|
|
refused("organisations", groupsOnly, organisations)
|
|
|
|
// Nothing was written by any refused request.
|
|
got := env.reload(t, user.ID)
|
|
if got == nil || got.IsActivated || got.DeletedAt.Valid || got.OrganisationID != nil || got.Name == nil || *got.Name != "gated" {
|
|
t.Fatalf("a refused request changed the user: %+v", got)
|
|
}
|
|
if stored := env.group(t, group.ID); stored == nil || stored.Name != "Gated "+stamp {
|
|
t.Fatalf("a refused request changed the group: %+v", stored)
|
|
}
|
|
if stored := env.organisation(t, organisation.ID); stored == nil || stored.Name != "Gated "+stamp {
|
|
t.Fatalf("a refused request changed the organisation: %+v", stored)
|
|
}
|
|
var made int64
|
|
if err := env.gdb.Raw(`SELECT (SELECT COUNT(*) FROM users WHERE email = ?) + (SELECT COUNT(*) FROM user_groups WHERE code = ?) + (SELECT COUNT(*) FROM golem15_user_organisations WHERE name = ?)`,
|
|
"made-"+stamp+"@example.test", "made-"+stamp, "Made "+stamp).Scan(&made).Error; err != nil || made != 0 {
|
|
t.Fatalf("a refused create wrote %d rows (%v)", made, err)
|
|
}
|
|
// The same routes open with the permission.
|
|
env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), usersOnly, nil)
|
|
env.expect(t, http.StatusOK, http.MethodGet, groupPath(group.ID), groupsOnly, nil)
|
|
env.expect(t, http.StatusOK, http.MethodGet, organisationPath(organisation.ID, ""), usersOnly, nil)
|
|
})
|
|
|
|
// T-12.1-19: no admin response carries a password, a hash or a one-time
|
|
// code.
|
|
t.Run("T-12.1-19", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stamp := adminStamp()
|
|
activation, reset := "activation-"+stamp, "reset-"+stamp
|
|
marker := "leak" + stamp
|
|
user := env.seedUser(t, "leak", func(u *models.User) {
|
|
u.Surname, u.IsActivated = &marker, false
|
|
u.ActivationCode, u.ResetPasswordCode = &activation, &reset
|
|
})
|
|
const plain = "leak-password-1"
|
|
id := strconv.FormatUint(uint64(user.ID), 10)
|
|
bodies := map[string]string{
|
|
"list": env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"?search="+marker), token, nil).Body.String(),
|
|
"show": env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil).Body.String(),
|
|
"partial": env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+id), token, nil).Body.String(),
|
|
"update": env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "Leak", "email": user.Email, "password": plain, "password_confirmation": plain}).Body.String(),
|
|
"mismatch": env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "Leak", "email": user.Email, "password": plain, "password_confirmation": "other-password-1"}).Body.String(),
|
|
"create": env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token,
|
|
map[string]any{"name": "Leak two", "email": "leak-two-" + stamp + "@example.test", "password": plain, "password_confirmation": plain}).Body.String(),
|
|
"bulk": env.bulk(t, token, "ban", user).Body.String(),
|
|
"action": env.expect(t, http.StatusOK, http.MethodPost, userPath(user.ID, "/actions/activate"), token, map[string]any{}).Body.String(),
|
|
}
|
|
stored := env.reload(t, user.ID)
|
|
if !bouncer.CheckPassword(stored.Password, plain) {
|
|
t.Fatal("the update did not store the submitted password")
|
|
}
|
|
for route, body := range bodies {
|
|
if strings.Contains(body, plain) || strings.Contains(body, activation) || strings.Contains(body, reset) ||
|
|
strings.Contains(body, stored.Password) || strings.Contains(body, user.Password) || strings.Contains(body, "$2a$") {
|
|
t.Fatalf("the %s response carries a secret: %s", route, body)
|
|
}
|
|
// A 422 names the refused field; no other response has such a key.
|
|
if route != "mismatch" {
|
|
assertNoSecret(t, body)
|
|
}
|
|
}
|
|
})
|
|
|
|
// T-12.1-20: protected columns are never written from a body.
|
|
t.Run("T-12.1-20", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stamp := adminStamp()
|
|
organisation := env.seedOrganisation(t, "Assign "+stamp)
|
|
user := env.seedUser(t, "assign", func(u *models.User) { u.IsActivated = false })
|
|
forged, err := bouncer.HashPassword(4, "forged-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same := func(step string) {
|
|
t.Helper()
|
|
got := env.reload(t, user.ID)
|
|
if got.IsActivated || got.ActivatedAt != nil || got.OrganisationID != nil || got.OrganisationRole != nil ||
|
|
len(got.Permissions) != 0 || got.Password != user.Password || got.TokensValidAfter != nil || got.MustChangePassword {
|
|
t.Fatalf("%s wrote a protected column: %+v", step, got)
|
|
}
|
|
}
|
|
// Plain columns in a body are dropped; the rest of the body is saved.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{
|
|
"name": "Assigned", "email": user.Email, "is_activated": true, "activated_at": "2020-01-01T00:00:00Z",
|
|
"organisation_id": organisation.ID, "organisation_role": "owner", "must_change_password": true,
|
|
"Password": forged, "tokens_valid_after": "2020-01-01T00:00:00Z",
|
|
})
|
|
same("a body with plain protected columns")
|
|
if got := env.reload(t, user.ID); got.Name == nil || *got.Name != "Assigned" {
|
|
t.Fatalf("the allowed part of the body was not saved: %+v", got.Name)
|
|
}
|
|
// permissions is written by the editor's validated path only.
|
|
for _, scalar := range []any{1, "all", true, []string{"blog.post"}} {
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "Assigned", "email": user.Email, "permissions": scalar})
|
|
same(fmt.Sprintf("permissions sent as %v", scalar))
|
|
}
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "Assigned", "email": user.Email, "permissions": map[string]int{"never.offered." + stamp: 1}})
|
|
same("an unknown permission code")
|
|
// A password without its confirmation is not a column write.
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "Assigned", "email": user.Email, "password": forged})
|
|
same("a password without confirmation")
|
|
// A create cannot switch the activation on either.
|
|
created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
|
|
"name": "Born", "email": "born-" + stamp + "@example.test", "password": "born-password-1", "password_confirmation": "born-password-1",
|
|
"is_activated": true, "organisation_id": organisation.ID,
|
|
})
|
|
if got := env.reload(t, createdID(t, created)); got.IsActivated || got.OrganisationID != nil {
|
|
t.Fatalf("a create wrote a protected column: %+v", got)
|
|
}
|
|
// The organisation is writable through its relation field only.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "Assigned", "email": user.Email, "organisation": organisation.ID})
|
|
if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != organisation.ID {
|
|
t.Fatal("the organisation relation field did not write organisation_id")
|
|
}
|
|
})
|
|
|
|
// T-12.1-21: an admin password reset ends the user's sessions.
|
|
t.Run("T-12.1-21", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
user := env.seedUser(t, "reset", nil)
|
|
status, old := env.userLogin(t, user.Email, "correct-horse")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("login = %d", status)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", old, nil)
|
|
time.Sleep(1100 * time.Millisecond) // token timestamps have one-second resolution
|
|
const next = "reset-password-2"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token,
|
|
map[string]any{"name": "reset", "email": user.Email, "password": next, "password_confirmation": next})
|
|
if got := env.reload(t, user.ID); got.TokensValidAfter == nil {
|
|
t.Fatal("an admin password reset did not stamp tokens_valid_after")
|
|
}
|
|
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", old, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("a token issued before the reset still works: %d", rec.Code)
|
|
}
|
|
if status, _ := env.userLogin(t, user.Email, next); status != http.StatusOK {
|
|
t.Fatalf("login with the new password = %d", status)
|
|
}
|
|
})
|
|
|
|
// T-12.1-22: exactly one invitation per created user and none on update.
|
|
t.Run("T-12.1-22", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stamp := adminStamp()
|
|
invited := "threat-invited-" + stamp + "@example.test"
|
|
body := map[string]any{"name": "Invited", "email": invited, "password": "invite-password-1", "password_confirmation": "invite-password-1", "send_invite": true}
|
|
id := createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, body))
|
|
if n := len(env.mails(invited)); n != 1 {
|
|
t.Fatalf("invitations for one created user = %d, want 1", n)
|
|
}
|
|
// The same body again is a duplicate email: no second mail.
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, body)
|
|
// An update carrying the flag sends nothing.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Invited", "email": invited, "send_invite": true})
|
|
if n := len(env.mails(invited)); n != 1 {
|
|
t.Fatalf("invitations after a failed create and an update = %d, want 1", n)
|
|
}
|
|
// No bulk or record action invites.
|
|
for _, name := range []string{"invite", "send_invite"} {
|
|
if rec := env.call(t, http.MethodPost, adminAPI(adminUsersPath+"/bulk/"+name), token, map[string]any{"ids": []uint{id}}); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("bulk %s = %d, want 404", name, rec.Code)
|
|
}
|
|
}
|
|
})
|
|
|
|
// T-12.1-23: a permanent delete leaves no orphan and needs the permission.
|
|
t.Run("T-12.1-23", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stranger := env.admin(t)
|
|
group := env.seedGroup(t, "Purge "+adminStamp(), "purge-"+adminStamp())
|
|
user := env.seedUser(t, "purge", nil)
|
|
env.join(t, user, group)
|
|
env.seedThrottle(t, user, "203.0.113.30", false, nil)
|
|
key := env.seedAvatar(t, user)
|
|
env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(user.ID, ""), stranger, nil)
|
|
if env.reload(t, user.ID) == nil || env.hangers(t, user) != (hangers{Throttle: 1, Groups: 1, Files: 1}) || !env.blobExists(t, key) {
|
|
t.Fatalf("a refused delete changed the user: %+v", env.hangers(t, user))
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodDelete, userPath(user.ID, ""), token, nil)
|
|
if env.reload(t, user.ID) != nil {
|
|
t.Fatal("the user row survived the permanent delete")
|
|
}
|
|
if left := env.hangers(t, user); left != (hangers{}) {
|
|
t.Fatalf("the permanent delete left user_throttle, users_groups or system_files rows: %+v", left)
|
|
}
|
|
if env.blobExists(t, key) {
|
|
t.Fatal("the permanent delete left the avatar blob")
|
|
}
|
|
})
|
|
|
|
// T-12.1-24: the admin columns never reach a user API payload.
|
|
t.Run("T-12.1-24", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
stamp := adminStamp()
|
|
group := env.seedGroup(t, "Payload "+stamp, "payload-"+stamp)
|
|
user := env.seedUser(t, "payload", func(u *models.User) { u.Permissions = models.PermissionSet{"payload." + stamp: 1} })
|
|
env.join(t, user, group)
|
|
if err := env.gdb.Exec(`UPDATE users SET last_seen = NOW() - INTERVAL '1 minute' WHERE id = ?`, user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
userToken, payloads, raws := env.userPayloads(t, user.Email, "correct-horse")
|
|
update := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/update", userToken, map[string]any{"name": "Payload", "surname": "Shown", "email": user.Email})
|
|
raws["update"] = update.Body.String()
|
|
raws["refresh"] = env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", userToken, nil).Body.String()
|
|
for name, raw := range raws {
|
|
for _, key := range []string{"last_seen", "created_at", "updated_at", "LastSeen", "CreatedAt", "UpdatedAt", "Permissions", "payload." + stamp, "Payload " + stamp} {
|
|
if strings.Contains(raw, key) {
|
|
t.Fatalf("the %s body carries %s: %s", name, key, raw)
|
|
}
|
|
}
|
|
}
|
|
for name, payload := range payloads {
|
|
if value, ok := payload["permissions"]; !ok || value != nil {
|
|
t.Fatalf("%s payload permissions = %v (present %v), want null", name, value, ok)
|
|
}
|
|
if groups, ok := payload["groups"].([]any); !ok || len(groups) != 0 {
|
|
t.Fatalf("%s payload groups = %v, want an empty list", name, payload["groups"])
|
|
}
|
|
}
|
|
// A user model marshalled as it is (another plugin's payload, a log
|
|
// line) carries none of the admin columns either.
|
|
loaded := env.reload(t, user.ID)
|
|
if loaded == nil || loaded.LastSeen == nil || loaded.Permissions["payload."+stamp] != 1 || loaded.CreatedAt.IsZero() {
|
|
t.Fatalf("the admin columns were not loaded: %+v", loaded)
|
|
}
|
|
marshalled, err := json.Marshal(loaded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lower := strings.ToLower(string(marshalled))
|
|
for _, key := range []string{"last_seen", "lastseen", "created_at", "createdat", "updated_at", "updatedat", "permissions", "payload." + stamp} {
|
|
if strings.Contains(lower, key) {
|
|
t.Fatalf("a marshalled user carries %s: %s", key, marshalled)
|
|
}
|
|
}
|
|
})
|
|
|
|
// T-12.1-25: a failing last_seen write never fails authentication.
|
|
t.Run("T-12.1-25", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
user := env.seedUser(t, "seen", nil)
|
|
drop := func() {
|
|
env.gdb.Exec(`DROP TRIGGER IF EXISTS threat_refuse_last_seen ON users`)
|
|
env.gdb.Exec(`DROP FUNCTION IF EXISTS threat_refuse_last_seen()`)
|
|
}
|
|
t.Cleanup(drop)
|
|
for _, stmt := range []string{
|
|
`CREATE OR REPLACE FUNCTION threat_refuse_last_seen() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'last_seen cannot be written in this test'; END $$ LANGUAGE plpgsql`,
|
|
`CREATE TRIGGER threat_refuse_last_seen BEFORE UPDATE OF last_seen ON users FOR EACH ROW EXECUTE FUNCTION threat_refuse_last_seen()`,
|
|
} {
|
|
if err := env.gdb.Exec(stmt).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := classes.TouchLastSeen(t.Context(), env.gdb, user.ID); err == nil {
|
|
t.Fatal("the trigger does not refuse the write")
|
|
}
|
|
status, token := env.userLogin(t, user.Email, "correct-horse")
|
|
if status != http.StatusOK || token == "" {
|
|
t.Fatalf("login with a failing last_seen write = %d", status)
|
|
}
|
|
if rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("refresh with a failing last_seen write = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.reload(t, user.ID); got.LastSeen != nil {
|
|
t.Fatalf("last_seen was written although the write is refused: %v", got.LastSeen)
|
|
}
|
|
drop()
|
|
// With the write allowed again, a login stamps it once.
|
|
env.userLogin(t, user.Email, "correct-horse")
|
|
first := env.reload(t, user.ID).LastSeen
|
|
env.userLogin(t, user.Email, "correct-horse")
|
|
if second := env.reload(t, user.ID).LastSeen; first == nil || second == nil || !second.Equal(*first) {
|
|
t.Fatalf("last_seen is stamped more than once per five minutes: %v then %v", first, second)
|
|
}
|
|
})
|
|
|
|
// T-12.1-27: the permission resolver's override, exactly-1 and wildcard
|
|
// rules.
|
|
t.Run("T-12.1-27", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
stamp := adminStamp()
|
|
store := func(group *models.UserGroup, permissions string) {
|
|
t.Helper()
|
|
if err := env.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, permissions, group.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
allow := env.seedGroup(t, "Allow "+stamp, "allow-"+stamp)
|
|
deny := env.seedGroup(t, "Deny "+stamp, "deny-"+stamp)
|
|
store(allow, `{"blog.post":1,"blog.edit":1,"shop.buy":-1,"wiki.*":1}`)
|
|
store(deny, `{"blog.post":-1,"shop.buy":-1,"blog.zero":0}`)
|
|
user := env.seedUser(t, "resolver", func(u *models.User) { u.Permissions = models.PermissionSet{"blog.edit": -1, "shop.buy": 1} })
|
|
env.join(t, user, allow, deny)
|
|
merged, err := classes.MergedPermissions(t.Context(), env.gdb, user.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Among groups the higher value wins; the user's own value replaces it.
|
|
want := map[string]int{"blog.post": 1, "blog.edit": -1, "shop.buy": 1, "wiki.*": 1, "blog.zero": 0}
|
|
if !reflect.DeepEqual(merged, want) {
|
|
t.Fatalf("merged = %v, want %v", merged, want)
|
|
}
|
|
for code, held := range map[string]bool{
|
|
"blog.post": true, // a group's allow beats another group's deny
|
|
"blog.edit": false, // a user-level deny overrides a group's allow
|
|
"shop.buy": true, // a user-level allow overrides the groups' deny
|
|
"blog.zero": false, // held only at exactly 1
|
|
"blog.none": false,
|
|
"wiki.page": true, // a stored trailing wildcard
|
|
"wiki.": false,
|
|
"blog.*": true, // an asked trailing wildcard
|
|
"*.buy": true, // an asked leading wildcard
|
|
"*.edit": false,
|
|
"nothing.*": false,
|
|
"*.nothing": false,
|
|
"wikipedia": false,
|
|
"shop.buy.x": false,
|
|
} {
|
|
if got := classes.HasPermission(merged, code); got != held {
|
|
t.Fatalf("HasPermission(%q) = %v, want %v", code, got, held)
|
|
}
|
|
if got, err := classes.UserHasPermission(t.Context(), env.gdb, user.ID, code); err != nil || got != held {
|
|
t.Fatalf("UserHasPermission(%q) = %v (%v), want %v", code, got, err, held)
|
|
}
|
|
}
|
|
})
|
|
|
|
// T-12.1-28 is T-12-18 revisited (the Phase 12 threat "a users_groups
|
|
// write path makes a site admin"): the groups field of the user form is
|
|
// the one writer of users_groups, and adding or removing a privileged
|
|
// group through it needs golem15.users.manage_privileged_groups.
|
|
t.Run("T-12.1-28", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
limited := env.admin(t, permAccessUsers)
|
|
manager := env.admin(t, permAccessUsers, permManagePrivileged)
|
|
stamp := adminStamp()
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
ordinary := env.seedGroup(t, "Ordinary "+stamp, "ordinary-"+stamp)
|
|
target := env.seedUser(t, "target", nil)
|
|
env.join(t, target, ordinary)
|
|
member := env.seedUser(t, "member", nil)
|
|
env.join(t, member, adminGroup, ordinary)
|
|
isAdmin := func(id uint) bool {
|
|
t.Helper()
|
|
held, err := classes.HasGroupCode(t.Context(), env.gdb, id, "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return held
|
|
}
|
|
put := func(token string, user *models.User, body map[string]any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
if _, ok := body["email"]; !ok {
|
|
body["email"] = user.Email
|
|
}
|
|
return env.call(t, http.MethodPut, userPath(user.ID, ""), token, body)
|
|
}
|
|
refused := func(step string, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
answer := adminDecode(t, rec)
|
|
if rec.Code != http.StatusForbidden || answer.Error.Message != privilegedGroupForbidden || len(answer.Error.Details["groups"]) != 1 {
|
|
t.Fatalf("%s = %d %s, want the privileged-group 403 on groups", step, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
create := func(token, email string, groups ...uint) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return env.call(t, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
|
|
"name": "Born", "email": email, "password": "born-password-1", "password_confirmation": "born-password-1", "groups": groups,
|
|
})
|
|
}
|
|
|
|
// Without the permission: add, remove and create are refused and the
|
|
// pivot, the other fields of the body and the site-admin answer stay.
|
|
before := env.pivotSnapshot(t)
|
|
if isAdmin(target.ID) || !isAdmin(member.ID) {
|
|
t.Fatal("seed: wrong site-admin state")
|
|
}
|
|
refused("add on update", put(limited, target, map[string]any{"name": "Promoted", "groups": []uint{ordinary.ID, adminGroup.ID}}))
|
|
refused("add, crafted with other fields", put(limited, target, map[string]any{
|
|
"name": "Promoted", "email": "promoted-" + stamp + "@example.test", "surname": "Crafted", "groups": []uint{adminGroup.ID},
|
|
}))
|
|
refused("remove on update", put(limited, member, map[string]any{"name": "Demoted", "groups": []uint{ordinary.ID}}))
|
|
refused("remove by clearing", put(limited, member, map[string]any{"groups": []uint{}}))
|
|
refusedEmail := "born-admin-" + stamp + "@example.test"
|
|
refused("create with the group", create(limited, refusedEmail, adminGroup.ID, ordinary.ID))
|
|
if got := env.pivotSnapshot(t); got != before {
|
|
t.Fatalf("a refused request changed users_groups:\n%s\nwant\n%s", got, before)
|
|
}
|
|
for _, user := range []*models.User{target, member} {
|
|
got := env.reload(t, user.ID)
|
|
if got.Email != user.Email || !reflect.DeepEqual(got.Name, user.Name) || got.Surname != nil {
|
|
t.Fatalf("a refused request saved another field of user %d: %+v", user.ID, got)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if isAdmin(target.ID) || !isAdmin(member.ID) {
|
|
t.Fatal("a refused request moved the site-admin membership")
|
|
}
|
|
// Ordinary memberships still need only the users permission.
|
|
if rec := put(limited, target, map[string]any{"name": "target", "groups": []uint{}}); rec.Code != http.StatusOK {
|
|
t.Fatalf("an ordinary change = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// With the permission each of them succeeds.
|
|
if rec := put(manager, target, map[string]any{"name": "target", "groups": []uint{adminGroup.ID}}); rec.Code != http.StatusOK || !isAdmin(target.ID) {
|
|
t.Fatalf("manager add = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := put(manager, member, map[string]any{"name": "member", "groups": []uint{ordinary.ID}}); rec.Code != http.StatusOK || isAdmin(member.ID) {
|
|
t.Fatalf("manager remove = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec := create(manager, "born-admin-ok-"+stamp+"@example.test", adminGroup.ID)
|
|
if rec.Code != http.StatusCreated || !isAdmin(createdID(t, rec)) {
|
|
t.Fatalf("manager create = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
// T-12.1-29: creating, re-coding to or from, and deleting a privileged
|
|
// group code.
|
|
t.Run("T-12.1-29", func(t *testing.T) {
|
|
stamp := adminStamp()
|
|
priv, second := "priv-"+stamp, "priv2-"+stamp
|
|
env := privilegedList(t, "admin", priv, second)
|
|
groupsOnly := env.admin(t, permAccessGroups)
|
|
manager := env.admin(t, permAccessGroups, permManagePrivileged)
|
|
privGroup := env.seedGroup(t, "Privileged "+stamp, priv)
|
|
plainGroup := env.seedGroup(t, "Plain "+stamp, "plain-"+stamp)
|
|
cases := []struct {
|
|
name string
|
|
run func(token string) *httptest.ResponseRecorder
|
|
status int
|
|
}{
|
|
{"create with a privileged code", func(token string) *httptest.ResponseRecorder {
|
|
return env.call(t, http.MethodPost, adminAPI(adminGroupsPath), token, map[string]any{"name": "Second " + stamp, "code": second})
|
|
}, http.StatusCreated},
|
|
{"re-code to a privileged code", func(token string) *httptest.ResponseRecorder {
|
|
return env.call(t, http.MethodPut, groupPath(plainGroup.ID), token, map[string]any{"name": "Plain " + stamp, "code": "admin-like-" + stamp})
|
|
}, http.StatusOK},
|
|
{"re-code from a privileged code", func(token string) *httptest.ResponseRecorder {
|
|
return env.call(t, http.MethodPut, groupPath(privGroup.ID), token, map[string]any{"name": "Privileged " + stamp, "code": "freed-" + stamp})
|
|
}, http.StatusOK},
|
|
{"delete a privileged group", func(token string) *httptest.ResponseRecorder {
|
|
return env.call(t, http.MethodDelete, groupPath(privGroup.ID), token, nil)
|
|
}, http.StatusOK},
|
|
}
|
|
// The second case re-codes an ordinary group to a privileged code.
|
|
cases[1].run = func(token string) *httptest.ResponseRecorder {
|
|
return env.call(t, http.MethodPut, groupPath(plainGroup.ID), token, map[string]any{"name": "Plain " + stamp, "code": second})
|
|
}
|
|
state := func() string {
|
|
t.Helper()
|
|
var rows []struct {
|
|
ID uint
|
|
Code *string
|
|
}
|
|
if err := env.gdb.Raw(`SELECT id, code FROM user_groups WHERE id IN (?, ?) OR code = ? ORDER BY id`, privGroup.ID, plainGroup.ID, second).Scan(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var b strings.Builder
|
|
for _, row := range rows {
|
|
code := "<nil>"
|
|
if row.Code != nil {
|
|
code = *row.Code
|
|
}
|
|
fmt.Fprintf(&b, "%d=%s;", row.ID, code)
|
|
}
|
|
return b.String()
|
|
}
|
|
before := state()
|
|
for _, tc := range cases {
|
|
rec := tc.run(groupsOnly)
|
|
answer := adminDecode(t, rec)
|
|
if rec.Code != http.StatusForbidden || answer.Error.Message != privilegedGroupCodeForbidden || len(answer.Error.Details["code"]) != 1 {
|
|
t.Fatalf("%s without the permission = %d %s", tc.name, rec.Code, rec.Body.String())
|
|
}
|
|
if got := state(); got != before {
|
|
t.Fatalf("%s without the permission changed the groups: %s, want %s", tc.name, got, before)
|
|
}
|
|
}
|
|
// A name-only edit of a privileged group needs only the groups permission.
|
|
if rec := env.call(t, http.MethodPut, groupPath(privGroup.ID), groupsOnly, map[string]any{"name": "Privileged renamed " + stamp, "code": priv}); rec.Code != http.StatusOK {
|
|
t.Fatalf("a name-only edit of a privileged group = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
// With the permission: create, re-code from, then delete what is left.
|
|
if rec := cases[0].run(manager); rec.Code != http.StatusCreated {
|
|
t.Fatalf("manager %s = %d %s", cases[0].name, rec.Code, rec.Body.String())
|
|
}
|
|
if rec := cases[2].run(manager); rec.Code != http.StatusOK {
|
|
t.Fatalf("manager %s = %d %s", cases[2].name, rec.Code, rec.Body.String())
|
|
}
|
|
if rec := env.call(t, http.MethodPut, groupPath(plainGroup.ID), manager, 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 rec := env.call(t, http.MethodDelete, groupPath(plainGroup.ID), manager, nil); rec.Code != http.StatusOK || env.group(t, plainGroup.ID) != nil {
|
|
t.Fatalf("manager delete of a privileged group = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
// T-12.1-30: no second, unguarded writer of users_groups.
|
|
t.Run("T-12.1-30", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers, permAccessGroups)
|
|
stamp := adminStamp()
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
ordinary := env.seedGroup(t, "Ordinary "+stamp, "ordinary-"+stamp)
|
|
organisation := env.seedOrganisation(t, "Writers "+stamp)
|
|
now := time.Now()
|
|
member := env.seedUser(t, "writer-member", func(u *models.User) { u.IsActivated = false })
|
|
plain := env.seedUser(t, "writer-plain", nil)
|
|
env.join(t, member, adminGroup, ordinary)
|
|
env.join(t, plain, ordinary)
|
|
before := env.pivotSnapshot(t)
|
|
same := func(step string) {
|
|
t.Helper()
|
|
if got := env.pivotSnapshot(t); got != before {
|
|
t.Fatalf("%s changed users_groups:\n%s\nwant\n%s", step, got, before)
|
|
}
|
|
}
|
|
for _, action := range []string{"activate", "deactivate", "restore", "ban", "unban"} {
|
|
if rec := env.bulk(t, token, action, member, plain); rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk %s = %d %s", action, rec.Code, rec.Body.String())
|
|
}
|
|
same("bulk " + action)
|
|
}
|
|
// Bring each record action into its applicable state, then run it.
|
|
if err := env.gdb.Exec(`UPDATE users SET is_activated = FALSE WHERE id = ?`, member.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/activate"), token, map[string]any{})
|
|
same("record activate")
|
|
env.bulk(t, token, "ban", member)
|
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/unban"), token, map[string]any{})
|
|
same("record unban")
|
|
env.seedThrottle(t, plain, "203.0.113.40", false, &now)
|
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(plain.ID, "/actions/unsuspend"), token, map[string]any{})
|
|
same("record unsuspend")
|
|
// The organisation members manager writes organisation_id only.
|
|
ids := map[string]any{"ids": []uint{member.ID, plain.ID}}
|
|
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(organisation.ID, "/relations/members/link"), token, ids)
|
|
if got := env.reload(t, member.ID); got.OrganisationID == nil || *got.OrganisationID != organisation.ID {
|
|
t.Fatal("the members link did not run")
|
|
}
|
|
same("organisation members link")
|
|
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(organisation.ID, "/relations/members/unlink"), token, ids)
|
|
same("organisation members unlink")
|
|
// A user update without the groups field and a group update.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(plain.ID, ""), token, map[string]any{"name": "Renamed", "email": plain.Email})
|
|
env.expect(t, http.StatusOK, http.MethodPut, groupPath(ordinary.ID), token, map[string]any{"name": "Ordinary renamed " + stamp, "code": "ordinary-" + stamp})
|
|
same("updates without the groups field")
|
|
|
|
// No relation manager exists for groups on either controller, so the
|
|
// relation routes (which do not ask the lock provider) cannot reach
|
|
// the pivot.
|
|
for _, path := range []string{
|
|
userPath(member.ID, "/relations/groups"),
|
|
userPath(member.ID, "/relations/Groups"),
|
|
adminAPI(adminUsersPath + "/schema/relation/groups"),
|
|
groupPath(ordinary.ID) + "/relations/users",
|
|
adminAPI(adminGroupsPath + "/schema/relation/users"),
|
|
organisationPath(organisation.ID, "/relations/groups"),
|
|
} {
|
|
if rec := env.call(t, http.MethodGet, path, token, nil); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET %s = %d, want 404", path, rec.Code)
|
|
}
|
|
}
|
|
for _, path := range []string{
|
|
userPath(plain.ID, "/relations/groups/link"),
|
|
groupPath(adminGroup.ID) + "/relations/users/link",
|
|
organisationPath(organisation.ID, "/relations/groups/link"),
|
|
} {
|
|
body := map[string]any{"ids": []uint{adminGroup.ID, plain.ID}}
|
|
if rec := env.call(t, http.MethodPost, path, token, body); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("POST %s = %d, want 404", path, rec.Code)
|
|
}
|
|
}
|
|
same("relation routes for groups")
|
|
if held, err := classes.HasGroupCode(t.Context(), env.gdb, plain.ID, "admin"); err != nil || held {
|
|
t.Fatalf("the plain user became a site admin: %v (%v)", held, err)
|
|
}
|
|
})
|
|
|
|
// T-12.1-31: the privileged list is read per request, compared
|
|
// case-sensitively, and a group without a code is never privileged.
|
|
t.Run("T-12.1-31", func(t *testing.T) {
|
|
stamp := adminStamp()
|
|
custom := "custom-" + stamp
|
|
env := privilegedList(t, custom)
|
|
limited := env.admin(t, permAccessUsers)
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
customGroup := env.seedGroup(t, "Custom "+stamp, custom)
|
|
upper := env.seedGroup(t, "Upper "+stamp, strings.ToUpper(custom))
|
|
uncoded := env.seedGroup(t, "Uncoded "+stamp, "")
|
|
options := env.groupOptions(t, limited, stamp)
|
|
if len(options) != 4 || !options[customGroup.ID] || options[adminGroup.ID] || options[upper.ID] || options[uncoded.ID] {
|
|
t.Fatalf("locked options under the overridden list = %v", options)
|
|
}
|
|
user := env.seedUser(t, "listed", nil)
|
|
put := func(groups ...uint) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return env.call(t, http.MethodPut, userPath(user.ID, ""), limited, map[string]any{"name": "listed", "email": user.Email, "groups": groups})
|
|
}
|
|
// admin is no longer privileged; the case variant and the group
|
|
// without a code never are.
|
|
if rec := put(adminGroup.ID, upper.ID, uncoded.ID); rec.Code != http.StatusOK {
|
|
t.Fatalf("ordinary groups under the overridden list = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := put(adminGroup.ID, customGroup.ID); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("the overridden privileged group = %d %s, want 403", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(adminGroup, upper, uncoded)) {
|
|
t.Fatalf("users_groups after the refusal = %v", got)
|
|
}
|
|
// The same request under the default list: read per request, so a new
|
|
// environment with another list decides differently.
|
|
byDefault := newAdminEnv(t)
|
|
defaultLimited := byDefault.admin(t, permAccessUsers)
|
|
rec := byDefault.call(t, http.MethodPut, userPath(user.ID, ""), defaultLimited, map[string]any{"name": "listed", "email": user.Email, "groups": []uint{adminGroup.ID, customGroup.ID, upper.ID, uncoded.ID}})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("adding the custom group under the default list = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = byDefault.call(t, http.MethodPut, userPath(user.ID, ""), defaultLimited, map[string]any{"name": "listed", "email": user.Email, "groups": []uint{customGroup.ID}})
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("removing admin under the default list = %d %s, want 403", rec.Code, rec.Body.String())
|
|
}
|
|
// The class functions agree.
|
|
codes := classes.PrivilegedGroupCodes(env.app.Config)
|
|
if !reflect.DeepEqual(codes, []string{custom}) || classes.IsPrivilegedCode(codes, upper.Code) || classes.IsPrivilegedCode(codes, nil) || !classes.IsPrivilegedCode(codes, customGroup.Code) {
|
|
t.Fatalf("privileged codes = %v", codes)
|
|
}
|
|
})
|
|
|
|
// T-12.1-34: group membership never reaches a user API payload.
|
|
t.Run("T-12.1-34", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
stamp := adminStamp()
|
|
group := env.seedGroup(t, "Hidden "+stamp, "admin")
|
|
user := env.seedUser(t, "hidden", nil)
|
|
env.join(t, user, group)
|
|
_, payloads, raws := env.userPayloads(t, user.Email, "correct-horse")
|
|
for name, payload := range payloads {
|
|
if groups, ok := payload["groups"].([]any); !ok || len(groups) != 0 {
|
|
t.Fatalf("%s payload groups = %v, want an empty list", name, payload["groups"])
|
|
}
|
|
if strings.Contains(raws[name], "Hidden "+stamp) || strings.Contains(raws[name], `"code":"admin"`) {
|
|
t.Fatalf("the %s body names the group: %s", name, raws[name])
|
|
}
|
|
}
|
|
loaded := models.User{}
|
|
if err := env.gdb.Preload("Groups").First(&loaded, user.ID).Error; err != nil || len(loaded.Groups) != 1 {
|
|
t.Fatalf("the relation did not load: %d groups (%v)", len(loaded.Groups), err)
|
|
}
|
|
marshalled, err := json.Marshal(loaded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if lower := strings.ToLower(string(marshalled)); strings.Contains(lower, "groups") || strings.Contains(lower, "hidden "+stamp) {
|
|
t.Fatalf("a marshalled user with loaded groups serializes them: %s", marshalled)
|
|
}
|
|
})
|
|
|
|
// T-12.1-38 (D-30): takeover of a privileged-group member's account by an
|
|
// administrator who holds only golem15.users.access_users.
|
|
t.Run("T-12.1-38", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
limited := env.admin(t, permAccessUsers)
|
|
manager := env.admin(t, permAccessUsers, permManagePrivileged)
|
|
stamp := adminStamp()
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
member := env.seedUser(t, "takeover", nil)
|
|
// The membership is given through the groups field, by an
|
|
// administrator who may.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager,
|
|
map[string]any{"name": "takeover", "email": member.Email, "groups": []uint{adminGroup.ID}})
|
|
original := env.reload(t, member.ID)
|
|
const attempted = "takeover-password-1"
|
|
takeover := "takeover-" + stamp + "@example.test"
|
|
refused := func(step string, body map[string]any, fields ...string) {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodPut, userPath(member.ID, ""), limited, body)
|
|
answer := adminDecode(t, rec)
|
|
if rec.Code != http.StatusForbidden || answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberForbidden {
|
|
t.Fatalf("%s = %d %s, want the privileged-member 403", step, rec.Code, rec.Body.String())
|
|
}
|
|
if len(answer.Error.Details) != len(fields) {
|
|
t.Fatalf("%s details = %v, want %v", step, answer.Error.Details, fields)
|
|
}
|
|
for _, field := range fields {
|
|
if len(answer.Error.Details[field]) != 1 {
|
|
t.Fatalf("%s has no detail on %s: %v", step, field, answer.Error.Details)
|
|
}
|
|
}
|
|
got := env.reload(t, member.ID)
|
|
if got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || got.Password != original.Password ||
|
|
!reflect.DeepEqual(got.TokensValidAfter, original.TokensValidAfter) {
|
|
t.Fatalf("%s changed the member: email=%q name=%v tokens_valid_after=%v", step, got.Email, got.Name, got.TokensValidAfter)
|
|
}
|
|
}
|
|
refused("an email change", map[string]any{"name": "takeover", "email": takeover}, "email")
|
|
refused("a password reset", map[string]any{"name": "takeover", "email": member.Email, "password": attempted, "password_confirmation": attempted}, "password")
|
|
refused("email, password and name in one body", map[string]any{"name": "Taken over", "email": takeover, "password": attempted, "password_confirmation": attempted}, "email", "password")
|
|
if status, _ := env.userLogin(t, member.Email, "correct-horse"); status != http.StatusOK {
|
|
t.Fatalf("the old password after the refused requests = %d", status)
|
|
}
|
|
if status, _ := env.userLogin(t, member.Email, attempted); status != http.StatusUnauthorized {
|
|
t.Fatalf("the attempted password signs in: %d", status)
|
|
}
|
|
if status, _ := env.userLogin(t, takeover, attempted); status == http.StatusOK {
|
|
t.Fatal("the attempted email and password sign in")
|
|
}
|
|
// A name-only update is an ordinary update.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "Renamed", "email": member.Email})
|
|
if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "Renamed" {
|
|
t.Fatal("a name-only update of a privileged member was not saved")
|
|
}
|
|
// With the permission both succeed.
|
|
managed := "managed-" + stamp + "@example.test"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Renamed", "email": managed})
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager,
|
|
map[string]any{"name": "Renamed", "email": managed, "password": "managed-password-1", "password_confirmation": "managed-password-1"})
|
|
if got := env.reload(t, member.ID); got.Email != managed || !bouncer.CheckPassword(got.Password, "managed-password-1") {
|
|
t.Fatalf("the manager's changes were not saved: %+v", got)
|
|
}
|
|
// Once the membership is removed the limited administrator may change
|
|
// the email.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Renamed", "email": managed, "groups": []uint{}})
|
|
released := "released-" + stamp + "@example.test"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "Renamed", "email": released})
|
|
if got := env.reload(t, member.ID); got.Email != released {
|
|
t.Fatalf("email after the membership was removed = %q", got.Email)
|
|
}
|
|
})
|
|
|
|
// T-12.1-39 (D-30): permanent delete of a privileged-group member.
|
|
t.Run("T-12.1-39", func(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
limited := env.admin(t, permAccessUsers)
|
|
manager := env.admin(t, permAccessUsers, permManagePrivileged)
|
|
stamp := adminStamp()
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
ordinary := env.seedGroup(t, "Ordinary "+stamp, "ordinary-"+stamp)
|
|
loaded := func(label string, groups ...*models.UserGroup) (*models.User, string) {
|
|
user := env.seedUser(t, label, nil)
|
|
env.join(t, user, groups...)
|
|
env.seedThrottle(t, user, "203.0.113.50", false, nil)
|
|
return user, env.seedAvatar(t, user)
|
|
}
|
|
first, firstKey := loaded("del-first", ordinary)
|
|
member, memberKey := loaded("del-member", adminGroup, ordinary)
|
|
last, lastKey := loaded("del-last", ordinary)
|
|
everyone := []*models.User{first, member, last}
|
|
keys := []string{firstKey, memberKey, lastKey}
|
|
before := map[uint]hangers{}
|
|
for _, user := range everyone {
|
|
before[user.ID] = env.hangers(t, user)
|
|
}
|
|
intact := func(step string) {
|
|
t.Helper()
|
|
for i, user := range everyone {
|
|
if env.reload(t, user.ID) == nil {
|
|
t.Fatalf("%s removed user %d", step, user.ID)
|
|
}
|
|
if got := env.hangers(t, user); got != before[user.ID] {
|
|
t.Fatalf("%s removed rows of user %d: %+v, want %+v", step, user.ID, got, before[user.ID])
|
|
}
|
|
if !env.blobExists(t, keys[i]) {
|
|
t.Fatalf("%s removed the avatar blob of user %d", step, user.ID)
|
|
}
|
|
}
|
|
}
|
|
refused := func(step string, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Message != privilegedMemberDeleteForbidden {
|
|
t.Fatalf("%s = %d %s, want the privileged-member delete 403", step, rec.Code, rec.Body.String())
|
|
}
|
|
intact(step)
|
|
}
|
|
refused("the form delete", env.call(t, http.MethodDelete, userPath(member.ID, ""), limited, nil))
|
|
// The member sits between two ordinary users, so rows before it were
|
|
// already deleted inside the transaction when it is refused.
|
|
refused("the bulk delete", env.bulk(t, limited, "delete", first, member, last))
|
|
// An ordinary user is deleted by the limited administrator.
|
|
spare, spareKey := loaded("del-spare", ordinary)
|
|
env.expect(t, http.StatusOK, http.MethodDelete, userPath(spare.ID, ""), limited, nil)
|
|
if env.reload(t, spare.ID) != nil || env.blobExists(t, spareKey) {
|
|
t.Fatal("an ordinary user was not deleted")
|
|
}
|
|
// With the permission both succeed.
|
|
if rec := env.bulk(t, manager, "delete", first, member); rec.Code != http.StatusOK {
|
|
t.Fatalf("manager bulk delete = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
other, _ := loaded("del-other", adminGroup)
|
|
env.expect(t, http.StatusOK, http.MethodDelete, userPath(other.ID, ""), manager, nil)
|
|
for _, user := range []*models.User{first, member, other} {
|
|
if env.reload(t, user.ID) != nil || env.hangers(t, user) != (hangers{}) {
|
|
t.Fatalf("the manager's delete left user %d: %+v", user.ID, env.hangers(t, user))
|
|
}
|
|
}
|
|
})
|
|
}
|