feat: add paginated API endpoint for groups (#27603)

backend-only changes from #27271; see that PR for summary of changes +
implementation details
This commit is contained in:
Andrew Aquino
2026-08-10 13:23:14 -07:00
committed by GitHub
parent ddb2799009
commit 6e07e2610f
19 changed files with 1189 additions and 0 deletions
+8
View File
@@ -526,6 +526,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
})
})
})
r.Route("/organizations/{organization}/paginated-groups", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
api.templateRBACEnabledMW,
httpmw.ExtractOrganizationParam(api.Database),
)
r.Get("/", api.paginatedGroups)
})
r.Route("/organizations/{organization}/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
r.Use(
+111
View File
@@ -14,6 +14,7 @@ import (
"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/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery"
@@ -550,6 +551,116 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) {
api.groups(rw, r)
}
// @Summary Get groups by organization (paginated)
// @ID get-groups-by-organization-paginated
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param organization path string true "Organization ID or name"
// @Param q query string false "Search query (see description for syntax and colon-quoting)"
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Param after_id query string false "After ID" format(uuid)
// @Success 200 {object} codersdk.PaginatedGroupsResponse
// @Description Unlike "Get groups by organization" (GET /organizations/{organization}/groups),
// @Description which authorizes each group individually via its ACL, this endpoint requires
// @Description organization-wide group read permission and does no per-group filtering. It is
// @Description therefore not a drop-in replacement: callers without org-wide group read receive
// @Description an error rather than a filtered subset.
// @Description
// @Description The `q` parameter uses the shared filter syntax. Bare terms (including multi-word)
// @Description perform a free-text search over group name and display name. `search:` is the only
// @Description accepted key and unknown keys return 400. Because group display names may contain
// @Description colons, a value with a colon must be quoted, e.g. `search:"team: frontend"`; an
// @Description unquoted colon fails with `Query element "team:" cannot start or end with ':'`.
// @Description
// @Description This endpoint returns group summaries without the member roster: each group
// @Description carries only `total_member_count` and no `members` field. Callers that need the
// @Description roster use the group members endpoint (GET /groups/{group}/members).
// @Router /api/v2/organizations/{organization}/paginated-groups [get]
func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
org := httpmw.OrganizationParam(r)
filterQuery := r.URL.Query().Get("q")
search, filterErrs := searchquery.Groups(filterQuery)
if len(filterErrs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid group search query.",
Validations: filterErrs,
})
return
}
paginationParams, ok := agpl.ParsePagination(rw, r)
if !ok {
return
}
groups, err := api.Database.GetGroupsByOrganizationIDPaginated(ctx, database.GetGroupsByOrganizationIDPaginatedParams{
OrganizationID: org.ID,
Search: search,
AfterID: paginationParams.AfterID,
// #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 {
httpapi.InternalServerError(rw, err)
return
}
if len(groups) == 0 {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.PaginatedGroupsResponse{
Groups: []codersdk.PaginatedGroup{},
Count: 0,
})
return
}
resp := codersdk.PaginatedGroupsResponse{
Groups: make([]codersdk.PaginatedGroup, 0, len(groups)),
Count: int(groups[0].Count),
}
// Fetch member counts for every group on the page in a single query to
// avoid an N+1 lookup. We intentionally do not hydrate the per-group member
// rosters here: they can be large, contain member PII, and a caller
// authorized to read a group is not necessarily authorized to read its
// membership. Callers that need the roster page it via the group members
// endpoint. Only the total member count is returned.
groupIDs := make([]uuid.UUID, len(groups))
for i, group := range groups {
groupIDs[i] = group.Group.ID
}
// nolint:gocritic // Member counts are returned even without member read
// access, matching GetGroupMembersCountByGroupID. The endpoint already
// authorized org-wide group read.
countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{
GroupIds: groupIDs,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
countByGroup := make(map[uuid.UUID]int64, len(countRows))
for _, row := range countRows {
countByGroup[row.GroupID] = row.MemberCount
}
for _, group := range groups {
resp.Groups = append(resp.Groups, db2sdk.PaginatedGroup(database.GetGroupsRow{
Group: group.Group,
OrganizationName: group.OrganizationName,
OrganizationDisplayName: group.OrganizationDisplayName,
}, int(countByGroup[group.Group.ID])))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get groups
// @ID get-groups
// @Security CoderSessionToken
+293
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"sort"
"strings"
"testing"
"time"
@@ -1259,3 +1260,295 @@ func TestGetGroupMembersPagination(t *testing.T) {
}
coderdtest.UsersPagination(ctx, t, client, setup, fetch)
}
func TestPaginatedGroups(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
}})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitLong)
// Create a deterministic set of groups. Names include mixed case, a pair
// that differs only by case, and one group with a distinct display name so
// ordering (LOWER(name)), the groups.id tiebreaker, and display-name search
// are all exercised. The org's implicit "Everyone" group also exists, so
// account for it in the expected counts.
type groupSpec struct {
name string
displayName string
}
specs := []groupSpec{
{name: "alpha"},
{name: "Bravo"},
{name: "charlie"},
{name: "Delta"},
{name: "echo"},
// "Dev" and "dev" collide once lowercased, forcing the groups.id
// tiebreaker to produce a deterministic order.
{name: "Dev"},
{name: "dev"},
{name: "zeta", displayName: "Frontend Squad"},
// A display name with a colon is searchable via a quoted search value.
{name: "team-fe", displayName: "Team: Frontend"},
}
for _, spec := range specs {
_, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: spec.name,
DisplayName: spec.displayName,
})
require.NoError(t, err)
}
// The org's implicit "Everyone" group is included in the paginated results.
totalGroups := len(specs) + 1
// Add a known member to the "alpha" group so member hydration can be
// asserted below.
_, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
alpha, err := userAdminClient.GroupByOrgAndName(ctx, user.OrganizationID, "alpha")
require.NoError(t, err)
_, err = userAdminClient.PatchGroup(ctx, alpha.ID, codersdk.PatchGroupRequest{
AddUsers: []string{member.ID.String()},
})
require.NoError(t, err)
t.Run("AllGroups", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{})
require.NoError(t, err)
require.Equal(t, totalGroups, resp.Count)
require.Len(t, resp.Groups, totalGroups)
// Verify deterministic ordering: lower(name) ascending, with ties
// broken by groups.id ascending. uuid string comparison matches
// Postgres' byte-wise uuid ordering.
for i := 1; i < len(resp.Groups); i++ {
prev, cur := resp.Groups[i-1], resp.Groups[i]
prevName, curName := strings.ToLower(prev.Name), strings.ToLower(cur.Name)
if prevName == curName {
require.Less(t, prev.ID.String(), cur.ID.String(),
"groups with equal lowercased names must be ordered by id")
} else {
require.Less(t, prevName, curName,
"groups must be ordered by lowercased name")
}
}
})
t.Run("MemberCount", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// The list endpoint returns each group's total member count but does
// not hydrate the member roster; callers page members separately via
// the group members endpoint. Assert the count is populated. The
// roster is omitted entirely: the slim PaginatedGroup type has no
// Members field, so re-adding roster hydration would fail to compile.
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: "alpha",
})
require.NoError(t, err)
require.Len(t, resp.Groups, 1)
require.Equal(t, "alpha", resp.Groups[0].Name)
require.Equal(t, 1, resp.Groups[0].TotalMemberCount)
})
t.Run("Search", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: "alpha",
})
require.NoError(t, err)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.Groups, 1)
require.Equal(t, "alpha", resp.Groups[0].Name)
})
t.Run("SearchNoResults", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: "does-not-exist",
})
require.NoError(t, err)
require.Equal(t, 0, resp.Count)
require.Empty(t, resp.Groups)
})
t.Run("SearchSubstring", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// A substring of the name matches, not just a prefix.
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: "harl",
})
require.NoError(t, err)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.Groups, 1)
require.Equal(t, "charlie", resp.Groups[0].Name)
})
t.Run("SearchCaseInsensitive", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// An uppercase query matches both "Dev" and "dev" case-insensitively.
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: "DEV",
})
require.NoError(t, err)
require.Equal(t, 2, resp.Count)
require.Len(t, resp.Groups, 2)
for _, g := range resp.Groups {
require.Equal(t, "dev", strings.ToLower(g.Name))
}
// The case-only collision is ordered deterministically by id.
require.Less(t, resp.Groups[0].ID.String(), resp.Groups[1].ID.String())
})
t.Run("SearchDisplayName", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Search matches the display name, not just the name.
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: "squad",
})
require.NoError(t, err)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.Groups, 1)
require.Equal(t, "zeta", resp.Groups[0].Name)
})
t.Run("SearchColonValue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// A display name containing a colon is searchable when the value is
// quoted via the search key, since an unquoted colon is a key:value
// delimiter.
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
SearchQuery: `search:"team: frontend"`,
})
require.NoError(t, err)
require.Equal(t, 1, resp.Count)
require.Len(t, resp.Groups, 1)
require.Equal(t, "team-fe", resp.Groups[0].Name)
})
t.Run("PageBoundaries", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Page through the results two at a time and ensure the union covers
// every group exactly once, with a stable Count on each page.
seen := make(map[string]struct{})
for offset := 0; offset < totalGroups; offset += 2 {
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
Pagination: codersdk.Pagination{Limit: 2, Offset: offset},
})
require.NoError(t, err)
require.Equal(t, totalGroups, resp.Count)
require.LessOrEqual(t, len(resp.Groups), 2)
for _, g := range resp.Groups {
_, dup := seen[g.Name]
require.False(t, dup, "group %q appeared on more than one page", g.Name)
seen[g.Name] = struct{}{}
}
}
require.Len(t, seen, totalGroups)
})
t.Run("AfterIDCursor", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Page through the results using after_id as a keyset cursor. The union
// must cover every group exactly once, in the same deterministic
// (LOWER(name), id) order, with no duplicates even across the
// "Dev"/"dev" case collision that relies on the id tiebreaker.
seen := make(map[uuid.UUID]struct{})
var after uuid.UUID
var prevName string
var prevID uuid.UUID
havePrev := false
for {
resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{
Pagination: codersdk.Pagination{Limit: 2, AfterID: after},
})
require.NoError(t, err)
if len(resp.Groups) == 0 {
break
}
require.LessOrEqual(t, len(resp.Groups), 2)
for _, g := range resp.Groups {
_, dup := seen[g.ID]
require.False(t, dup, "group %q returned on more than one page", g.Name)
seen[g.ID] = struct{}{}
name := strings.ToLower(g.Name)
if havePrev {
if name == prevName {
require.Less(t, prevID.String(), g.ID.String(),
"ties must advance by id")
} else {
require.Less(t, prevName, name,
"groups must stay ordered by lowercased name")
}
}
prevName, prevID, havePrev = name, g.ID, true
}
after = resp.Groups[len(resp.Groups)-1].ID
}
require.Len(t, seen, totalGroups)
})
t.Run("OrganizationIsolation", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// A second organization with its own group must never appear in the
// first org's results, and the first org's Count must exclude it. This
// exercises the organization_id filter for exclusion, which a
// single-org test cannot.
//nolint:gocritic // Only owners can create organizations.
otherOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "other-org",
})
require.NoError(t, err)
// Reuse a name that also exists in the first org to prove isolation is
// by organization, not by name.
otherGroup, err := client.CreateGroup(ctx, otherOrg.ID, codersdk.CreateGroupRequest{
Name: "alpha",
})
require.NoError(t, err)
resp, err := client.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{})
require.NoError(t, err)
require.Equal(t, totalGroups, resp.Count)
for _, g := range resp.Groups {
require.Equal(t, user.OrganizationID, g.OrganizationID)
require.NotEqual(t, otherGroup.ID, g.ID)
}
// The second org returns only its own groups: the created group plus
// that org's implicit "Everyone" group.
otherResp, err := client.OrganizationGroupsPaginated(ctx, otherOrg.ID, codersdk.PaginatedGroupsRequest{})
require.NoError(t, err)
require.Equal(t, 2, otherResp.Count)
for _, g := range otherResp.Groups {
require.Equal(t, otherOrg.ID, g.OrganizationID)
}
})
}