mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user