diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index afc1f9e7f31..526fe4b48fa 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5761,6 +5761,66 @@ const docTemplate = `{ ] } }, + "/api/v2/organizations/{organization}/paginated-groups": { + "get": { + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe ` + "`" + `q` + "`" + ` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. ` + "`" + `search:` + "`" + ` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. ` + "`" + `search:\"team: frontend\"` + "`" + `; an\nunquoted colon fails with ` + "`" + `Query element \"team:\" cannot start or end with ':'` + "`" + `.\n\nThis endpoint returns group summaries without the member roster: each group\ncarries only ` + "`" + `total_member_count` + "`" + ` and no ` + "`" + `members` + "`" + ` field. Callers that need the\nroster use the group members endpoint (GET /groups/{group}/members).", + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get groups by organization (paginated)", + "operationId": "get-groups-by-organization-paginated", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query (see description for syntax and colon-quoting)", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.PaginatedGroupsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/organizations/{organization}/paginated-members": { "get": { "produces": [ @@ -22514,6 +22574,59 @@ const docTemplate = `{ } } }, + "codersdk.PaginatedGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "TotalMemberCount is the number of members in the group, shown even when\nthe caller cannot read individual members. The roster itself is not\nreturned by this endpoint.", + "type": "integer" + } + } + }, + "codersdk.PaginatedGroupsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.PaginatedGroup" + } + } + } + }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c14b8c2c828..a009b6e7086 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5102,6 +5102,62 @@ ] } }, + "/api/v2/organizations/{organization}/paginated-groups": { + "get": { + "description": "Unlike \"Get groups by organization\" (GET /organizations/{organization}/groups),\nwhich authorizes each group individually via its ACL, this endpoint requires\norganization-wide group read permission and does no per-group filtering. It is\ntherefore not a drop-in replacement: callers without org-wide group read receive\nan error rather than a filtered subset.\n\nThe `q` parameter uses the shared filter syntax. Bare terms (including multi-word)\nperform a free-text search over group name and display name. `search:` is the only\naccepted key and unknown keys return 400. Because group display names may contain\ncolons, a value with a colon must be quoted, e.g. `search:\"team: frontend\"`; an\nunquoted colon fails with `Query element \"team:\" cannot start or end with ':'`.\n\nThis endpoint returns group summaries without the member roster: each group\ncarries only `total_member_count` and no `members` field. Callers that need the\nroster use the group members endpoint (GET /groups/{group}/members).", + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get groups by organization (paginated)", + "operationId": "get-groups-by-organization-paginated", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query (see description for syntax and colon-quoting)", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.PaginatedGroupsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/organizations/{organization}/paginated-members": { "get": { "produces": ["application/json"], @@ -20567,6 +20623,59 @@ } } }, + "codersdk.PaginatedGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "TotalMemberCount is the number of members in the group, shown even when\nthe caller cannot read individual members. The roster itself is not\nreturned by this endpoint.", + "type": "integer" + } + } + }, + "codersdk.PaginatedGroupsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.PaginatedGroup" + } + } + } + }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 445f0222424..67bff9308ad 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -377,6 +377,24 @@ func Group(row database.GetGroupsRow, members []database.GroupMember, totalMembe } } +// PaginatedGroup converts a group row into the slim summary returned by the +// paginated groups endpoint, which omits the member roster and carries only +// the total member count. +func PaginatedGroup(row database.GetGroupsRow, totalMemberCount int) codersdk.PaginatedGroup { + return codersdk.PaginatedGroup{ + ID: row.Group.ID, + Name: row.Group.Name, + DisplayName: row.Group.DisplayName, + OrganizationID: row.Group.OrganizationID, + AvatarURL: row.Group.AvatarURL, + TotalMemberCount: totalMemberCount, + QuotaAllowance: int(row.Group.QuotaAllowance), + Source: codersdk.GroupSource(row.Group.Source), + OrganizationName: row.OrganizationName, + OrganizationDisplayName: row.OrganizationDisplayName, + } +} + func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { // Use a stable sort, similarly to how we would sort in the query, note that // we don't sort in the query because order varies depending on the table diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 58e8b989b25..3f68893f59b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3901,6 +3901,17 @@ func (q *querier) GetGroups(ctx context.Context, arg database.GetGroupsParams) ( return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroups)(ctx, arg) } +func (q *querier) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg database.GetGroupsByOrganizationIDPaginatedParams) ([]database.GetGroupsByOrganizationIDPaginatedRow, error) { + // Required to have permission to read all groups in the organization. This + // mirrors PaginatedOrganizationMembers: a single org-wide read check with no + // per-row post-filter, so that SQL LIMIT/OFFSET and COUNT(*) OVER() stay + // consistent across pages. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceGroup.InOrg(arg.OrganizationID)); err != nil { + return nil, err + } + return q.db.GetGroupsByOrganizationIDPaginated(ctx, arg) +} + func (q *querier) GetHealthSettings(ctx context.Context) (string, error) { // No authz checks return q.db.GetHealthSettings(ctx) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 82c1dd47e1f..a1dd4f79731 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2353,6 +2353,19 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(arg).Asserts(mem, policy.ActionRead) })) + s.Run("GetGroupsByOrganizationIDPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + arg := database.GetGroupsByOrganizationIDPaginatedParams{OrganizationID: o.ID, LimitOpt: 0} + rows := []database.GetGroupsByOrganizationIDPaginatedRow{{ + Group: g, + OrganizationName: o.Name, + OrganizationDisplayName: o.DisplayName, + Count: 1, + }} + dbm.EXPECT().GetGroupsByOrganizationIDPaginated(gomock.Any(), arg).Return(rows, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead).Returns(rows) + })) s.Run("PaginatedOrganizationMembers", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { o := testutil.Fake(s.T(), faker, database.Organization{}) u := testutil.Fake(s.T(), faker, database.User{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 2cbc9710286..f27c4271dbe 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2153,6 +2153,14 @@ func (m queryMetricsStore) GetGroups(ctx context.Context, arg database.GetGroups return r0, r1 } +func (m queryMetricsStore) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg database.GetGroupsByOrganizationIDPaginatedParams) ([]database.GetGroupsByOrganizationIDPaginatedRow, error) { + start := time.Now() + r0, r1 := m.s.GetGroupsByOrganizationIDPaginated(ctx, arg) + m.queryLatencies.WithLabelValues("GetGroupsByOrganizationIDPaginated").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetGroupsByOrganizationIDPaginated").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetHealthSettings(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetHealthSettings(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 38ae689b768..f172027dad5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3990,6 +3990,21 @@ func (mr *MockStoreMockRecorder) GetGroups(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroups", reflect.TypeOf((*MockStore)(nil).GetGroups), ctx, arg) } +// GetGroupsByOrganizationIDPaginated mocks base method. +func (m *MockStore) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg database.GetGroupsByOrganizationIDPaginatedParams) ([]database.GetGroupsByOrganizationIDPaginatedRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByOrganizationIDPaginated", ctx, arg) + ret0, _ := ret[0].([]database.GetGroupsByOrganizationIDPaginatedRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupsByOrganizationIDPaginated indicates an expected call of GetGroupsByOrganizationIDPaginated. +func (mr *MockStoreMockRecorder) GetGroupsByOrganizationIDPaginated(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByOrganizationIDPaginated", reflect.TypeOf((*MockStore)(nil).GetGroupsByOrganizationIDPaginated), ctx, arg) +} + // GetHealthSettings mocks base method. func (m *MockStore) GetHealthSettings(ctx context.Context) (string, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a06b767ec90..a7f52a88464 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -624,6 +624,7 @@ type sqlcQuerier interface { GetGroupMembersCountByGroupIDs(ctx context.Context, arg GetGroupMembersCountByGroupIDsParams) ([]GetGroupMembersCountByGroupIDsRow, error) // A limit of 0 means "no limit". GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) + GetGroupsByOrganizationIDPaginated(ctx context.Context, arg GetGroupsByOrganizationIDPaginatedParams) ([]GetGroupsByOrganizationIDPaginatedRow, error) GetHealthSettings(ctx context.Context) (string, error) // Returns the highest group AI budget across the groups the user belongs to, // breaking ties by the earliest organization membership. Implements the diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7a33169b35f..28f64aad7e8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15125,6 +15125,101 @@ func (q *sqlQuerier) GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetG return items, nil } +const getGroupsByOrganizationIDPaginated = `-- name: GetGroupsByOrganizationIDPaginated :many +SELECT + groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source, groups.chat_spend_limit_micros, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND groups.organization_id = $1 + -- Keyset pagination cursor. When @after_id is set, return only groups + -- ordered after it, matching the ORDER BY (LOWER(name), id) below. This + -- lets callers page without duplicated or skipped rows even if groups are + -- inserted or deleted between page requests. + AND CASE + WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN + (LOWER(groups.name), groups.id) > ( + SELECT LOWER(name), id FROM groups WHERE id = $2 + ) + ELSE true + END + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN $3 :: text != '' THEN ( + groups.name ILIKE concat('%', $3, '%') + OR groups.display_name ILIKE concat('%', $3, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC, groups.id ASC OFFSET $4 +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF($5 :: int, 0) +` + +type GetGroupsByOrganizationIDPaginatedParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + Search string `db:"search" json:"search"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type GetGroupsByOrganizationIDPaginatedRow struct { + Group Group `db:"group" json:"group"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg GetGroupsByOrganizationIDPaginatedParams) ([]GetGroupsByOrganizationIDPaginatedRow, error) { + rows, err := q.db.QueryContext(ctx, getGroupsByOrganizationIDPaginated, + arg.OrganizationID, + arg.AfterID, + arg.Search, + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetGroupsByOrganizationIDPaginatedRow + for rows.Next() { + var i GetGroupsByOrganizationIDPaginatedRow + if err := rows.Scan( + &i.Group.ID, + &i.Group.Name, + &i.Group.OrganizationID, + &i.Group.AvatarURL, + &i.Group.QuotaAllowance, + &i.Group.DisplayName, + &i.Group.Source, + &i.Group.ChatSpendLimitMicros, + &i.OrganizationName, + &i.OrganizationDisplayName, + &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 insertAllUsersGroup = `-- name: InsertAllUsersGroup :one INSERT INTO groups ( id, diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index da687d7d1ee..20e0448deff 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -91,6 +91,44 @@ WHERE LIMIT NULLIF(@limit_opt :: int, 0) ; +-- name: GetGroupsByOrganizationIDPaginated :many +SELECT + sqlc.embed(groups), + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND groups.organization_id = @organization_id + -- Keyset pagination cursor. When @after_id is set, return only groups + -- ordered after it, matching the ORDER BY (LOWER(name), id) below. This + -- lets callers page without duplicated or skipped rows even if groups are + -- inserted or deleted between page requests. + AND CASE + WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN + (LOWER(groups.name), groups.id) > ( + SELECT LOWER(name), id FROM groups WHERE id = @after_id + ) + ELSE true + END + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN @search :: text != '' THEN ( + groups.name ILIKE concat('%', @search, '%') + OR groups.display_name ILIKE concat('%', @search, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC, groups.id ASC OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF(@limit_opt :: int, 0); + -- name: InsertGroup :one INSERT INTO groups ( id, diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index dd829b65b53..290b58485d1 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -174,6 +174,30 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } +// Groups parses a group search query using the standard filter syntax shared +// with the rest of the dashboard. Bare terms (including multi-word terms) +// become a free-text search over group name and display name. A value that +// contains a colon must be quoted or supplied via the explicit search key, +// e.g. search:"team: frontend", because an unquoted colon is otherwise treated +// as a key:value delimiter. Unknown keys are rejected, which keeps room for +// real key:value filters in the future. +func Groups(query string) (string, []codersdk.ValidationError) { + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + values.Add("search", term) + return nil + }) + if len(errors) > 0 { + return "", errors + } + + parser := httpapi.NewQueryParamParser() + search := parser.String(values, "", "search") + parser.ErrorExcessParams(values) + return search, parser.Errors +} + func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) { query = strings.TrimSpace(query) if query == "" { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index ea82f343345..46d6cd1b942 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1753,3 +1753,94 @@ func TestSearchChats(t *testing.T) { }) } } + +func TestSearchGroups(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + Query string + Expected string + ExpectedErrorContains string + }{ + { + Name: "Empty", + Query: "", + Expected: "", + }, + { + Name: "SingleWord", + Query: "alpha", + Expected: "alpha", + }, + { + // Groups support free-text search, so an unquoted multi-word query + // is joined into a single search value instead of being rejected as + // a duplicate param. + Name: "MultiWord", + Query: "front end", + Expected: "front end", + }, + { + Name: "CaseInsensitive", + Query: "AlPhA", + Expected: "alpha", + }, + { + Name: "MultiWordCaseInsensitive", + Query: "Front End", + Expected: "front end", + }, + { + Name: "TrimsSurroundingSpaces", + Query: " alpha ", + Expected: "alpha", + }, + { + // Structured key:value queries are not supported for groups; the + // unrecognized key surfaces as an invalid query param. Rejecting + // unknown keys leaves room for real key:value filters later. + Name: "StructuredKeyValueRejected", + Query: "name:alpha", + ExpectedErrorContains: "is not a valid query param", + }, + { + // The explicit search key is supported. + Name: "SearchKey", + Query: "search:alpha", + Expected: "alpha", + }, + { + // A colon-containing name is searchable when quoted via the search + // key, since group display names may legally contain colons. + Name: "QuotedColonValue", + Query: `search:"team: frontend"`, + Expected: "team: frontend", + }, + { + // An unquoted colon is treated as a key:value delimiter, so a bare + // colon term is rejected. Users must quote it (see QuotedColonValue). + Name: "BareColonRejected", + Query: "team: frontend", + ExpectedErrorContains: "cannot start or end with ':'", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + search, errs := searchquery.Groups(c.Query) + if c.ExpectedErrorContains != "" { + require.True(t, len(errs) > 0, "expect some errors") + var s strings.Builder + for _, err := range errs { + _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) + } + require.Contains(t, s.String(), c.ExpectedErrorContains) + } else { + require.Len(t, errs, 0, "expected no error") + require.Equal(t, c.Expected, search, "expected search value") + } + }) + } +} diff --git a/codersdk/groups.go b/codersdk/groups.go index fc777100f49..11fac75f58c 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -47,6 +47,49 @@ type GroupMembersResponse struct { Count int `json:"count"` } +type PaginatedGroupsResponse struct { + Groups []PaginatedGroup `json:"groups"` + Count int `json:"count"` +} + +// PaginatedGroup is a group summary returned by the paginated groups endpoint. +// It deliberately omits the member roster (which the endpoint does not return) +// and exposes only the total member count. Fetch the roster via the group +// members endpoint. +type PaginatedGroup struct { + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + // TotalMemberCount is the number of members in the group, shown even when + // the caller cannot read individual members. The roster itself is not + // returned by this endpoint. + TotalMemberCount int `json:"total_member_count"` + AvatarURL string `json:"avatar_url" format:"uri"` + QuotaAllowance int `json:"quota_allowance"` + Source GroupSource `json:"source"` + OrganizationName string `json:"organization_name"` + OrganizationDisplayName string `json:"organization_display_name"` +} + +// PaginatedGroupsRequest are the filters for a paginated groups request. +// Groups only support free-text search, so unlike UsersRequest it exposes no +// key:value filters that the endpoint would reject. +type PaginatedGroupsRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +func (req PaginatedGroupsRequest) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + if req.SearchQuery != "" { + q.Set("q", req.SearchQuery) + } + r.URL.RawQuery = q.Encode() + } +} + func (g Group) IsEveryone() bool { return g.ID == g.OrganizationID } @@ -134,6 +177,31 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st return resp, ReadBodyAsJSON(res, &resp) } +// OrganizationGroupsPaginated lists filtered and paginated groups in an +// organization. Unlike Groups (GET /groups), which authorizes each group +// individually via its ACL, this endpoint requires organization-wide group +// read permission and does no per-group filtering. It is therefore not a +// drop-in replacement for Groups: callers without org-wide group read will +// receive an error rather than a filtered subset. +func (c *Client) OrganizationGroupsPaginated(ctx context.Context, orgID uuid.UUID, req PaginatedGroupsRequest) (PaginatedGroupsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/paginated-groups", orgID.String()), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return PaginatedGroupsResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return PaginatedGroupsResponse{}, ReadBodyAsError(res) + } + var resp PaginatedGroupsResponse + return resp, ReadBodyAsJSON(res, &resp) +} + type GroupRequest struct { ExcludeMembers bool `json:"exclude_members"` } diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 8b6b252d19d..92da32c8353 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2317,6 +2317,77 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get groups by organization (paginated) + +### Code samples + +```sh +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginated-groups \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/organizations/{organization}/paginated-groups` + +Unlike "Get groups by organization" (GET /organizations/{organization}/groups), +which authorizes each group individually via its ACL, this endpoint requires +organization-wide group read permission and does no per-group filtering. It is +therefore not a drop-in replacement: callers without org-wide group read receive +an error rather than a filtered subset. + +The `q` parameter uses the shared filter syntax. Bare terms (including multi-word) +perform a free-text search over group name and display name. `search:` is the only +accepted key and unknown keys return 400. Because group display names may contain +colons, a value with a colon must be quoted, e.g. `search:"team: frontend"`; an +unquoted colon fails with `Query element "team:" cannot start or end with ':'`. + +This endpoint returns group summaries without the member roster: each group +carries only `total_member_count` and no `members` field. Callers that need the +roster use the group members endpoint (GET /groups/{group}/members). + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|-------------------------------------------------------------| +| `organization` | path | string | true | Organization ID or name | +| `q` | query | string | false | Search query (see description for syntax and colon-quoting) | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | +| `after_id` | query | string(uuid) | false | After ID | + +### Example responses + +> 200 Response + +```json +{ + "count": 0, + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.PaginatedGroupsResponse](schemas.md#codersdkpaginatedgroupsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Serve provisioner daemon ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9235fe55c49..d51bd77dccc 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9778,6 +9778,67 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen | ยป `[any property]` | array of string | false | | | | `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. | +## codersdk.PaginatedGroup + +```json +{ + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------------|----------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `organization_display_name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `quota_allowance` | integer | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | +| `total_member_count` | integer | false | | Total member count is the number of members in the group, shown even when the caller cannot read individual members. The roster itself is not returned by this endpoint. | + +## codersdk.PaginatedGroupsResponse + +```json +{ + "count": 0, + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|-------------------------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `groups` | array of [codersdk.PaginatedGroup](#codersdkpaginatedgroup) | false | | | + ## codersdk.PaginatedMembersResponse ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 8df7476400e..7fbdc5d7767 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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( diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index be7d3b42229..839a55e3647 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -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 diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 8ccbe81b165..ae1dee58350 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -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) + } + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 60c148f87af..0222095336d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6917,6 +6917,47 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } +// From codersdk/groups.go +/** + * PaginatedGroup is a group summary returned by the paginated groups endpoint. + * It deliberately omits the member roster (which the endpoint does not return) + * and exposes only the total member count. Fetch the roster via the group + * members endpoint. + */ +export interface PaginatedGroup { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly organization_id: string; + /** + * TotalMemberCount is the number of members in the group, shown even when + * the caller cannot read individual members. The roster itself is not + * returned by this endpoint. + */ + readonly total_member_count: number; + readonly avatar_url: string; + readonly quota_allowance: number; + readonly source: GroupSource; + readonly organization_name: string; + readonly organization_display_name: string; +} + +// From codersdk/groups.go +/** + * PaginatedGroupsRequest are the filters for a paginated groups request. + * Groups only support free-text search, so unlike UsersRequest it exposes no + * key:value filters that the endpoint would reject. + */ +export interface PaginatedGroupsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/groups.go +export interface PaginatedGroupsResponse { + readonly groups: readonly PaginatedGroup[]; + readonly count: number; +} + // From codersdk/organizations.go export interface PaginatedMembersRequest { readonly limit?: number;