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
+2
View File
@@ -461,6 +461,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
)
r.Get("/", api.groupByOrganization)
r.Get("/members", api.groupMembersByOrganization)
})
})
r.Route("/provisionerkeys", func(r chi.Router) {
@@ -545,6 +546,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.group)
r.Patch("/", api.patchGroup)
r.Delete("/", api.deleteGroup)
r.Get("/members", api.groupMembers)
})
})
r.Route("/workspace-quota", func(r chi.Router) {
+104 -7
View File
@@ -5,15 +5,18 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"golang.org/x/xerrors"
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/codersdk"
)
@@ -393,6 +396,7 @@ func (api *API) groupByOrganization(rw http.ResponseWriter, r *http.Request) {
// @Produce json
// @Tags Enterprise
// @Param group path string true "Group id"
// @Param exclude_members query bool false "Exclude members from the response"
// @Success 200 {object} codersdk.Group
// @Router /groups/{group} [get]
func (api *API) group(rw http.ResponseWriter, r *http.Request) {
@@ -401,18 +405,23 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
group = httpmw.GroupParam(r)
)
excludeMembers, _ := strconv.ParseBool(r.URL.Query().Get("exclude_members"))
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
if err != nil {
httpapi.InternalServerError(rw, err)
}
users, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
users := []database.GroupMember{}
if !excludeMembers {
users, err = api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
}
memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{
@@ -431,6 +440,94 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
}, users, int(memberCount)))
}
// @Summary Get group members by organization and group name
// @ID get-group-members-by-organization-and-group-name
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param organization path string true "Organization ID" format(uuid)
// @Param groupName path string true "Group name"
// @Param q query string false "Member search query"
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.GroupMembersResponse
// @Router /organizations/{organization}/groups/{groupName}/members [get]
func (api *API) groupMembersByOrganization(rw http.ResponseWriter, r *http.Request) {
api.groupMembers(rw, r)
}
// @Summary Get group members by group ID
// @ID get-group-members-by-group-id
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param group path string true "Group id"
// @Param q query string false "Member search query"
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.GroupMembersResponse
// @Router /groups/{group}/members [get]
func (api *API) groupMembers(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
group = httpmw.GroupParam(r)
)
filterQuery := r.URL.Query().Get("q")
userFilterParams, filterErrs := searchquery.Users(filterQuery)
if len(filterErrs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid member search query.",
Validations: filterErrs,
})
return
}
paginationParams, ok := agpl.ParsePagination(rw, r)
if !ok {
return
}
members, err := api.Database.GetGroupMembersByGroupIDPaginated(ctx, database.GetGroupMembersByGroupIDPaginatedParams{
AfterID: paginationParams.AfterID,
GroupID: group.ID,
IncludeSystem: false,
Search: userFilterParams.Search,
Name: userFilterParams.Name,
Status: userFilterParams.Status,
RbacRole: userFilterParams.RbacRole,
LastSeenBefore: userFilterParams.LastSeenBefore,
LastSeenAfter: userFilterParams.LastSeenAfter,
CreatedAfter: userFilterParams.CreatedAfter,
CreatedBefore: userFilterParams.CreatedBefore,
GithubComUserID: userFilterParams.GithubComUserID,
LoginType: userFilterParams.LoginType,
// #nosec G115 - Pagination offsets are small and fit in int32
OffsetOpt: int32(paginationParams.Offset),
// #nosec G115 - Pagination limits are small and fit in int32
LimitOpt: int32(paginationParams.Limit),
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
if len(members) == 0 {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupMembersResponse{
Users: nil,
Count: 0,
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupMembersResponse{
Users: db2sdk.ReducedUsersFromGroupMemberRows(members),
Count: int(members[0].Count),
})
}
// @Summary Get groups by organization
// @ID get-groups-by-organization
// @Security CoderSessionToken
+129 -11
View File
@@ -1,6 +1,7 @@
package coderd_test
import (
"context"
"net/http"
"sort"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
@@ -584,7 +586,7 @@ func TestPatchGroup(t *testing.T) {
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitLong)
group, err := userAdminClient.Group(ctx, user.OrganizationID)
group, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Equal(t, 0, group.QuotaAllowance)
@@ -636,7 +638,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
ggroup, err := userAdminClient.Group(ctx, group.ID)
ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Equal(t, group, ggroup)
})
@@ -686,7 +688,7 @@ func TestGroup(t *testing.T) {
require.Contains(t, group.Members, user2.ReducedUser)
require.Contains(t, group.Members, user3.ReducedUser)
ggroup, err := userAdminClient.Group(ctx, group.ID)
ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
normalizeGroupMembers(&group)
normalizeGroupMembers(&ggroup)
@@ -694,6 +696,38 @@ func TestGroup(t *testing.T) {
require.Equal(t, group, ggroup)
})
t.Run("WithoutMembers", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
}})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin())
_, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "hi",
})
require.NoError(t, err)
group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
AddUsers: []string{user2.ID.String(), user3.ID.String()},
})
require.NoError(t, err)
require.Contains(t, group.Members, user2.ReducedUser)
require.Contains(t, group.Members, user3.ReducedUser)
ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{
ExcludeMembers: true,
})
require.NoError(t, err)
require.Len(t, ggroup.Members, 0)
})
t.Run("RegularUserReadGroup", func(t *testing.T) {
t.Parallel()
@@ -714,7 +748,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
ggroup, err := client1.Group(ctx, group.ID)
ggroup, err := client1.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err, "regular users can read groups unless workspace sharing is disabled")
normalizeGroupMembers(&group)
normalizeGroupMembers(&ggroup)
@@ -760,7 +794,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
_, err = client1.Group(ctx, group.ID)
_, err = client1.Group(ctx, group.ID, codersdk.GroupRequest{})
require.Error(t, err, "regular users cannot read groups when workspace sharing is disabled")
cerr, ok := codersdk.AsError(err)
require.True(t, ok)
@@ -797,7 +831,7 @@ func TestGroup(t *testing.T) {
err = userAdminClient.DeleteUser(ctx, user1.ID)
require.NoError(t, err)
group, err = userAdminClient.Group(ctx, group.ID)
group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.NotContains(t, group.Members, user1.ReducedUser)
})
@@ -832,7 +866,7 @@ func TestGroup(t *testing.T) {
user1, err = userAdminClient.UpdateUserStatus(ctx, user1.ID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err)
group, err = userAdminClient.Group(ctx, group.ID)
group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Len(t, group.Members, 2)
require.Contains(t, group.Members, user1.ReducedUser)
@@ -854,7 +888,7 @@ func TestGroup(t *testing.T) {
AddUsers: []string{anotherUser.ID.String()},
})
group, err = userAdminClient.Group(ctx, group.ID)
group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Len(t, group.Members, 3)
require.Contains(t, group.Members, user1.ReducedUser)
@@ -916,7 +950,7 @@ func TestGroup(t *testing.T) {
prebuildsUser, err := client.User(ctx, database.PrebuildsSystemUserID.String())
require.NoError(t, err)
// The 'Everyone' group always has an ID that matches the organization ID.
group, err := userAdminClient.Group(ctx, user.OrganizationID)
group, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Len(t, group.Members, 4)
require.Equal(t, "Everyone", group.Name)
@@ -971,7 +1005,7 @@ func TestGroups(t *testing.T) {
normalizeGroupMembers(&group2)
// Fetch everyone group for comparison
everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID)
everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{})
require.NoError(t, err)
normalizeGroupMembers(&everyoneGroup)
@@ -1052,7 +1086,7 @@ func TestDeleteGroup(t *testing.T) {
err = userAdminClient.DeleteGroup(ctx, group1.ID)
require.NoError(t, err)
_, err = userAdminClient.Group(ctx, group1.ID)
_, err = userAdminClient.Group(ctx, group1.ID, codersdk.GroupRequest{})
require.Error(t, err)
cerr, ok := codersdk.AsError(err)
require.True(t, ok)
@@ -1114,3 +1148,87 @@ func TestDeleteGroup(t *testing.T) {
require.Equal(t, http.StatusBadRequest, cerr.StatusCode())
})
}
func TestGetGroupMembersFilter(t *testing.T) {
t.Parallel()
client, db, first := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
OIDCConfig: &coderd.OIDCConfig{
AllowSignups: true,
},
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleUserAdmin())
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
group, err := userAdminClient.CreateGroup(setupCtx, first.OrganizationID, codersdk.CreateGroupRequest{
Name: "filtered",
})
require.NoError(t, err)
setup := func(users []codersdk.User) {
userIDs := make([]string, len(users))
for i, user := range users {
userIDs[i] = user.ID.String()
}
group, err = userAdminClient.PatchGroup(setupCtx, group.ID, codersdk.PatchGroupRequest{
AddUsers: userIDs,
})
require.NoError(t, err)
}
fetch := func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
res, err := userAdminClient.GroupMembers(testCtx, group.ID, req)
require.NoError(t, err)
return res.Users
}
coderdtest.UsersFilter(setupCtx, t, client, db, setup, fetch)
}
func TestGetGroupMembersPagination(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleUserAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
group, err := userAdminClient.CreateGroup(ctx, first.OrganizationID, codersdk.CreateGroupRequest{
Name: "paginated",
})
require.NoError(t, err)
setup := func(users []codersdk.User) {
userIDs := make([]string, len(users))
for i, user := range users {
userIDs[i] = user.ID.String()
}
group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
AddUsers: userIDs,
})
require.NoError(t, err)
}
fetch := func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int) {
group, err := userAdminClient.GroupMembers(ctx, group.ID, req)
require.NoError(t, err)
return group.Users, group.Count
}
coderdtest.UsersPagination(ctx, t, client, setup, fetch)
}