feat: add new group members endpoint with filtering and pagination (#23067)

Partially addresses #21813 (still need to make changes to the "add user"
button to be complete)

Since there are a lot of user tests already, I moved them into
`coderdtest` to be shared.
This commit is contained in:
Asher
2026-03-20 12:43:03 -08:00
committed by GitHub
parent f135ffdb3a
commit 24ab216dd1
22 changed files with 1742 additions and 724 deletions
+28
View File
@@ -234,6 +234,34 @@ func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.Red
return slice.List(members, ReducedUserFromGroupMember)
}
func UserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) database.User {
return database.User{
ID: member.UserID,
Email: member.UserEmail,
Username: member.UserUsername,
HashedPassword: member.UserHashedPassword,
CreatedAt: member.UserCreatedAt,
UpdatedAt: member.UserUpdatedAt,
Status: member.UserStatus,
RBACRoles: member.UserRbacRoles,
LoginType: member.UserLoginType,
AvatarURL: member.UserAvatarUrl,
Deleted: member.UserDeleted,
LastSeenAt: member.UserLastSeenAt,
QuietHoursSchedule: member.UserQuietHoursSchedule,
Name: member.UserName,
GithubComUserID: member.UserGithubComUserID,
}
}
func ReducedUserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) codersdk.ReducedUser {
return ReducedUser(UserFromGroupMemberRow(member))
}
func ReducedUsersFromGroupMemberRows(members []database.GetGroupMembersByGroupIDPaginatedRow) []codersdk.ReducedUser {
return slice.List(members, ReducedUserFromGroupMemberRow)
}
func ReducedUsers(users []database.User) []codersdk.ReducedUser {
return slice.List(users, ReducedUser)
}
+4
View File
@@ -2892,6 +2892,10 @@ func (q *querier) GetGroupMembersByGroupID(ctx context.Context, arg database.Get
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupID)(ctx, arg)
}
func (q *querier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupIDPaginated)(ctx, arg)
}
func (q *querier) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) {
if _, err := q.GetGroupByID(ctx, arg.GroupID); err != nil { // AuthZ check
return 0, err
+9
View File
@@ -1188,6 +1188,15 @@ func (s *MethodTestSuite) TestGroup() {
check.Args(arg).Asserts(gm, policy.ActionRead)
}))
s.Run("GetGroupMembersByGroupIDPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
g := testutil.Fake(s.T(), faker, database.Group{})
u := testutil.Fake(s.T(), faker, database.User{})
gm := testutil.Fake(s.T(), faker, database.GetGroupMembersByGroupIDPaginatedRow{GroupID: g.ID, UserID: u.ID})
arg := database.GetGroupMembersByGroupIDPaginatedParams{GroupID: g.ID, IncludeSystem: false}
dbm.EXPECT().GetGroupMembersByGroupIDPaginated(gomock.Any(), arg).Return([]database.GetGroupMembersByGroupIDPaginatedRow{gm}, nil).AnyTimes()
check.Args(arg).Asserts(gm, policy.ActionRead)
}))
s.Run("GetGroupMembersCountByGroupID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
g := testutil.Fake(s.T(), faker, database.Group{})
arg := database.GetGroupMembersCountByGroupIDParams{GroupID: g.ID, IncludeSystem: false}
@@ -1472,6 +1472,14 @@ func (m queryMetricsStore) GetGroupMembersByGroupID(ctx context.Context, arg dat
return r0, r1
}
func (m queryMetricsStore) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) {
start := time.Now()
r0, r1 := m.s.GetGroupMembersByGroupIDPaginated(ctx, arg)
m.queryLatencies.WithLabelValues("GetGroupMembersByGroupIDPaginated").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetGroupMembersByGroupIDPaginated").Inc()
return r0, r1
}
func (m queryMetricsStore) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetGroupMembersCountByGroupID(ctx, arg)
+15
View File
@@ -2704,6 +2704,21 @@ func (mr *MockStoreMockRecorder) GetGroupMembersByGroupID(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupID", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupID), ctx, arg)
}
// GetGroupMembersByGroupIDPaginated mocks base method.
func (m *MockStore) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetGroupMembersByGroupIDPaginated", ctx, arg)
ret0, _ := ret[0].([]database.GetGroupMembersByGroupIDPaginatedRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetGroupMembersByGroupIDPaginated indicates an expected call of GetGroupMembersByGroupIDPaginated.
func (mr *MockStoreMockRecorder) GetGroupMembersByGroupIDPaginated(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupIDPaginated", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupIDPaginated), ctx, arg)
}
// GetGroupMembersCountByGroupID mocks base method.
func (m *MockStore) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) {
m.ctrl.T.Helper()
+4
View File
@@ -393,6 +393,10 @@ func (gm GroupMember) RBACObject() rbac.Object {
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
}
func (gm GetGroupMembersByGroupIDPaginatedRow) RBACObject() rbac.Object {
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
}
// PrebuiltWorkspaceResource defines the interface for types that can be identified as prebuilt workspaces
// and converted to their corresponding prebuilt workspace RBAC object.
type PrebuiltWorkspaceResource interface {
+1
View File
@@ -297,6 +297,7 @@ type sqlcQuerier interface {
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
GetGroupMembers(ctx context.Context, includeSystem bool) ([]GroupMember, error)
GetGroupMembersByGroupID(ctx context.Context, arg GetGroupMembersByGroupIDParams) ([]GroupMember, error)
GetGroupMembersByGroupIDPaginated(ctx context.Context, arg GetGroupMembersByGroupIDPaginatedParams) ([]GetGroupMembersByGroupIDPaginatedRow, error)
// Returns the total count of members in a group. Shows the total
// count even if the caller does not have read access to ResourceGroupMember.
// They only need ResourceGroup read access.
+206
View File
@@ -7404,6 +7404,212 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, arg GetGroupM
return items, nil
}
const getGroupMembersByGroupIDPaginated = `-- name: GetGroupMembersByGroupIDPaginated :many
SELECT
user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id, COUNT(*) OVER() AS count
FROM
group_members_expanded
WHERE
group_members_expanded.group_id = $1
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the username field, so select all
-- rows after the cursor.
(LOWER(user_username)) > (
SELECT
LOWER(user_username)
FROM
group_members_expanded
WHERE
group_id = $1
AND user_id = $2
)
)
ELSE true
END
-- Start filters
-- Filter by email or username
AND CASE
WHEN $3 :: text != '' THEN (
user_email ILIKE concat('%', $3, '%')
OR user_username ILIKE concat('%', $3, '%')
)
ELSE true
END
-- Filter by name (display name)
AND CASE
WHEN $4 :: text != '' THEN
user_name ILIKE concat('%', $4, '%')
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality($5 :: user_status[]) > 0 THEN
user_status = ANY($5 :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality($6 :: text[]) > 0 AND 'member' != ANY($6 :: text[]) THEN
user_rbac_roles && $6 :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at <= $7
ELSE true
END
AND CASE
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at >= $8
ELSE true
END
-- Filter by created_at
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at <= $9
ELSE true
END
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= $10
ELSE true
END
-- Filter by system type
AND CASE
WHEN $11::bool THEN TRUE
ELSE user_is_system = false
END
AND CASE
WHEN $12 :: bigint != 0 THEN
user_github_com_user_id = $12
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality($13 :: login_type[]) > 0 THEN
user_login_type = ANY($13 :: login_type[])
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(user_username) ASC OFFSET $14
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($15 :: int, 0)
`
type GetGroupMembersByGroupIDPaginatedParams struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Name string `db:"name" json:"name"`
Status []UserStatus `db:"status" json:"status"`
RbacRole []string `db:"rbac_role" json:"rbac_role"`
LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"`
LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"`
CreatedBefore time.Time `db:"created_before" json:"created_before"`
CreatedAfter time.Time `db:"created_after" json:"created_after"`
IncludeSystem bool `db:"include_system" json:"include_system"`
GithubComUserID int64 `db:"github_com_user_id" json:"github_com_user_id"`
LoginType []LoginType `db:"login_type" json:"login_type"`
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
}
type GetGroupMembersByGroupIDPaginatedRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
UserEmail string `db:"user_email" json:"user_email"`
UserUsername string `db:"user_username" json:"user_username"`
UserHashedPassword []byte `db:"user_hashed_password" json:"user_hashed_password"`
UserCreatedAt time.Time `db:"user_created_at" json:"user_created_at"`
UserUpdatedAt time.Time `db:"user_updated_at" json:"user_updated_at"`
UserStatus UserStatus `db:"user_status" json:"user_status"`
UserRbacRoles []string `db:"user_rbac_roles" json:"user_rbac_roles"`
UserLoginType LoginType `db:"user_login_type" json:"user_login_type"`
UserAvatarUrl string `db:"user_avatar_url" json:"user_avatar_url"`
UserDeleted bool `db:"user_deleted" json:"user_deleted"`
UserLastSeenAt time.Time `db:"user_last_seen_at" json:"user_last_seen_at"`
UserQuietHoursSchedule string `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"`
UserName string `db:"user_name" json:"user_name"`
UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"`
UserIsSystem bool `db:"user_is_system" json:"user_is_system"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
GroupName string `db:"group_name" json:"group_name"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
Count int64 `db:"count" json:"count"`
}
func (q *sqlQuerier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg GetGroupMembersByGroupIDPaginatedParams) ([]GetGroupMembersByGroupIDPaginatedRow, error) {
rows, err := q.db.QueryContext(ctx, getGroupMembersByGroupIDPaginated,
arg.GroupID,
arg.AfterID,
arg.Search,
arg.Name,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
arg.LastSeenAfter,
arg.CreatedBefore,
arg.CreatedAfter,
arg.IncludeSystem,
arg.GithubComUserID,
pq.Array(arg.LoginType),
arg.OffsetOpt,
arg.LimitOpt,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetGroupMembersByGroupIDPaginatedRow
for rows.Next() {
var i GetGroupMembersByGroupIDPaginatedRow
if err := rows.Scan(
&i.UserID,
&i.UserEmail,
&i.UserUsername,
&i.UserHashedPassword,
&i.UserCreatedAt,
&i.UserUpdatedAt,
&i.UserStatus,
pq.Array(&i.UserRbacRoles),
&i.UserLoginType,
&i.UserAvatarUrl,
&i.UserDeleted,
&i.UserLastSeenAt,
&i.UserQuietHoursSchedule,
&i.UserName,
&i.UserGithubComUserID,
&i.UserIsSystem,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
&i.Count,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGroupMembersCountByGroupID = `-- name: GetGroupMembersCountByGroupID :one
SELECT COUNT(*)
FROM group_members_expanded
+104
View File
@@ -17,6 +17,110 @@ WHERE group_id = @group_id
user_is_system = false
END;
-- name: GetGroupMembersByGroupIDPaginated :many
SELECT
*, COUNT(*) OVER() AS count
FROM
group_members_expanded
WHERE
group_members_expanded.group_id = @group_id
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the username field, so select all
-- rows after the cursor.
(LOWER(user_username)) > (
SELECT
LOWER(user_username)
FROM
group_members_expanded
WHERE
group_id = @group_id
AND user_id = @after_id
)
)
ELSE true
END
-- Start filters
-- Filter by email or username
AND CASE
WHEN @search :: text != '' THEN (
user_email ILIKE concat('%', @search, '%')
OR user_username ILIKE concat('%', @search, '%')
)
ELSE true
END
-- Filter by name (display name)
AND CASE
WHEN @name :: text != '' THEN
user_name ILIKE concat('%', @name, '%')
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality(@status :: user_status[]) > 0 THEN
user_status = ANY(@status :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
user_rbac_roles && @rbac_role :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at <= @last_seen_before
ELSE true
END
AND CASE
WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at >= @last_seen_after
ELSE true
END
-- Filter by created_at
AND CASE
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at <= @created_before
ELSE true
END
AND CASE
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= @created_after
ELSE true
END
-- Filter by system type
AND CASE
WHEN @include_system::bool THEN TRUE
ELSE user_is_system = false
END
AND CASE
WHEN @github_com_user_id :: bigint != 0 THEN
user_github_com_user_id = @github_com_user_id
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality(@login_type :: login_type[]) > 0 THEN
user_login_type = ANY(@login_type :: login_type[])
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(user_username) ASC OFFSET @offset_opt
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF(@limit_opt :: int, 0);
-- name: GetGroupMembersCountByGroupID :one
-- Returns the total count of members in a group. Shows the total
-- count even if the caller does not have read access to ResourceGroupMember.