test: pin every mitigated Phase 12.1 threat of the admin screens in TestPhase121Threats
- one subtest per threat id T-12.1-18 to T-12.1-25, 27 to 31, 34, 38 and 39 - T-12.1-28 revisits T-12-18: the groups field is the one writer of users_groups - T-12.1-38 and T-12.1-39 cover the takeover guard for privileged-group members
This commit is contained in:
950
phase121_security_test.go
Normal file
950
phase121_security_test.go
Normal file
@@ -0,0 +1,950 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user