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 := "" 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)) } } }) }