From cea4d3cfdc3d14e6f9959d4cb3ce1380fe35dae0 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 17:43:03 +0000 Subject: [PATCH 01/18] feat: add paginated members route --- coderd/coderd.go | 3 +++ coderd/members.go | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index ab8e99d29dea8..ec8499ab86e60 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1002,6 +1002,9 @@ func New(options *Options) *API { }) }) }) + r.Route("/paginated-members", func(r chi.Router) { + r.Get("/", api.paginatedMembers) + }) r.Route("/members", func(r chi.Router) { r.Get("/", api.listMembers) r.Route("/roles", func(r chi.Router) { diff --git a/coderd/members.go b/coderd/members.go index c89b4c9c09c1a..ed78304a0ec9a 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -178,6 +178,19 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } +// @Summary Paginated organization members +// @ID paginated-organization-members +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Success 200 {object} []codersdk.OrganizationMemberWithUserData +// @Router /organizations/{organization}/paginated-members [get] +func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + httpapi.Write(ctx, rw, http.StatusNotImplemented, nil) +} + // @Summary Assign role to organization member // @ID assign-role-to-organization-member // @Security CoderSessionToken From e97c9dbd88754f8c38e00f2c023c5f5c9b8c55da Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 18:45:40 +0000 Subject: [PATCH 02/18] feat: add request and response types --- coderd/members.go | 4 +++- codersdk/organizations.go | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/coderd/members.go b/coderd/members.go index ed78304a0ec9a..29722834dbe07 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -188,7 +188,9 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Router /organizations/{organization}/paginated-members [get] func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - httpapi.Write(ctx, rw, http.StatusNotImplemented, nil) + + resp := codersdk.PaginatedMembersResponse{} + httpapi.Write(ctx, rw, http.StatusOK, resp) } // @Summary Assign role to organization member diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 781baaaa5d5d6..2c1f9dc986b7c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -81,6 +81,22 @@ type OrganizationMemberWithUserData struct { OrganizationMember `table:"m,recursive_inline"` } +type PaginatedMembersRequest struct { + // + OrganizationID uuid.UUID `table:"organization id" json:"organization_id" format:"uuid"` + // + Limit int `json:"limit,omitempty"` + // + Offset int `json:"offset,omitempty"` +} + +type PaginatedMembersResponse struct { + // + Members []OrganizationMemberWithUserData + // + Count int `json:"count"` +} + type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,organization_name"` // DisplayName will default to the same value as `Name` if not provided. From 34003dcd1edd17530e2934919766f8bcffecbfba Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 19:07:28 +0000 Subject: [PATCH 03/18] feat: create new sql query and generate code --- coderd/apidoc/docs.go | 37 +++++++++ coderd/apidoc/swagger.json | 33 ++++++++ coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dbmem/dbmem.go | 9 +++ coderd/database/dbmetrics/querymetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 75 +++++++++++++++++ .../database/queries/organizationmembers.sql | 23 ++++++ docs/reference/api/members.md | 81 +++++++++++++++++++ site/src/api/typesGenerated.ts | 13 +++ 11 files changed, 298 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8f90cd5c205a2..15b087f819e2e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2971,6 +2971,43 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/paginated-members": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Paginated organization members", + "operationId": "paginated-organization-members", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + } + } + } + } + } + }, "/organizations/{organization}/provisionerdaemons": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fcfe56d3fc4aa..df1f58359fced 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2607,6 +2607,39 @@ } } }, + "/organizations/{organization}/paginated-members": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Paginated organization members", + "operationId": "paginated-organization-members", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + } + } + } + } + } + }, "/organizations/{organization}/provisionerdaemons": { "get": { "security": [ diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a4d76fa0198ed..75c6524e4605f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3581,6 +3581,10 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.OrganizationMembers)(ctx, arg) } +func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { + panic("not implemented") +} + func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { template, err := q.db.GetTemplateByID(ctx, templateID) if err != nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 7f7ff987ff544..5ad0f23572e03 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9584,6 +9584,15 @@ func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.Organi return tmp, nil } +func (q *FakeQuerier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + panic("not implemented") +} + func (q *FakeQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(_ context.Context, templateID uuid.UUID) error { err := validateDatabaseType(templateID) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 0d021f978151b..407d9e48bfcf8 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2278,6 +2278,13 @@ func (m queryMetricsStore) OrganizationMembers(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { + start := time.Now() + r0, r1 := m.s.PaginatedOrganizationMembers(ctx, arg) + m.queryLatencies.WithLabelValues("PaginatedOrganizationMembers").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { start := time.Now() r0 := m.s.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, templateID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6e07614f4cb3f..fbe4d0745fbb0 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4823,6 +4823,21 @@ func (mr *MockStoreMockRecorder) PGLocks(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PGLocks", reflect.TypeOf((*MockStore)(nil).PGLocks), ctx) } +// PaginatedOrganizationMembers mocks base method. +func (m *MockStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaginatedOrganizationMembers", ctx, arg) + ret0, _ := ret[0].([]database.PaginatedOrganizationMembersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaginatedOrganizationMembers indicates an expected call of PaginatedOrganizationMembers. +func (mr *MockStoreMockRecorder) PaginatedOrganizationMembers(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationMembers", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationMembers), ctx, arg) +} + // Ping mocks base method. func (m *MockStore) Ping(ctx context.Context) (time.Duration, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 28227797c7e3f..d72469650f0ea 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -478,6 +478,7 @@ type sqlcQuerier interface { // - Use just 'user_id' to get all orgs a user is a member of // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) + PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2d38ab38b0f25..433eef67c7cc7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5270,6 +5270,81 @@ func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMe return items, nil } +const paginatedOrganizationMembers = `-- name: PaginatedOrganizationMembers :many +SELECT + organization_members.user_id, organization_members.organization_id, organization_members.created_at, organization_members.updated_at, organization_members.roles, + users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles", + COUNT(*) OVER() AS count +FROM + organization_members + INNER JOIN + users ON organization_members.user_id = users.id AND users.deleted = false +WHERE + -- Filter by organization id + CASE + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = $1 + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. + LOWER(username) ASC OFFSET $2 +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF($3 :: int, 0) +` + +type PaginatedOrganizationMembersParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type PaginatedOrganizationMembersRow struct { + OrganizationMember OrganizationMember `db:"organization_member" json:"organization_member"` + Username string `db:"username" json:"username"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + GlobalRoles pq.StringArray `db:"global_roles" json:"global_roles"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) { + rows, err := q.db.QueryContext(ctx, paginatedOrganizationMembers, arg.OrganizationID, arg.OffsetOpt, arg.LimitOpt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaginatedOrganizationMembersRow + for rows.Next() { + var i PaginatedOrganizationMembersRow + if err := rows.Scan( + &i.OrganizationMember.UserID, + &i.OrganizationMember.OrganizationID, + &i.OrganizationMember.CreatedAt, + &i.OrganizationMember.UpdatedAt, + pq.Array(&i.OrganizationMember.Roles), + &i.Username, + &i.AvatarURL, + &i.Name, + &i.Email, + &i.GlobalRoles, + &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 updateMemberRoles = `-- name: UpdateMemberRoles :one UPDATE organization_members diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 71304c8883602..9e603ded03526 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -66,3 +66,26 @@ WHERE user_id = @user_id AND organization_id = @org_id RETURNING *; + +-- name: PaginatedOrganizationMembers :many +SELECT + sqlc.embed(organization_members), + users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles", + COUNT(*) OVER() AS count +FROM + organization_members + INNER JOIN + users ON organization_members.user_id = users.id AND users.deleted = false +WHERE + -- Filter by organization id + CASE + WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = @organization_id + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. + LOWER(username) ASC OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF(@limit_opt :: int, 0); diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 5dc39cee2d088..11b0f1c50cc86 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -813,6 +813,87 @@ curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Paginated organization members + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginated-members \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/paginated-members` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|------|--------|----------|-----------------| +| `organization` | path | string | true | Organization ID | + +### Example responses + +> 200 Response + +```json +[ + { + "avatar_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "email": "string", + "global_roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OrganizationMemberWithUserData](schemas.md#codersdkorganizationmemberwithuserdata) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|----------------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» created_at` | string(date-time) | false | | | +| `» email` | string | false | | | +| `» global_roles` | array | false | | | +| `»» display_name` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» roles` | array | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» user_id` | string(uuid) | false | | | +| `» username` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get site member roles ### Code samples diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 222c07575b969..6fdfb5ea9d9a1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1484,6 +1484,19 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } +// From codersdk/organizations.go +export interface PaginatedMembersRequest { + readonly organization_id: string; + readonly limit?: number; + readonly offset?: number; +} + +// From codersdk/organizations.go +export interface PaginatedMembersResponse { + readonly Members: readonly OrganizationMemberWithUserData[]; + readonly count: number; +} + // From codersdk/pagination.go export interface Pagination { readonly after_id?: string; From 9edb0887fa73b92920a53490bf398bd02cd95bda Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 21:21:37 +0000 Subject: [PATCH 04/18] fix: add dbauthz tests --- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/dbauthz/dbauthz_test.go | 16 +++++++++++++ coderd/database/dbmem/dbmem.go | 32 ++++++++++++++++++++++++- coderd/database/modelmethods.go | 4 ++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 75c6524e4605f..d567285a47219 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3582,7 +3582,7 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza } func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { - panic("not implemented") + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.PaginatedOrganizationMembers)(ctx, arg) } func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 614a357efcbc5..575744727ae60 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -985,6 +985,22 @@ func (s *MethodTestSuite) TestOrganization() { mem, policy.ActionRead, ) })) + s.Run("PaginatedOrganizationMembers", s.Subtest(func(db database.Store, check *expects) { + o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ + OrganizationID: o.ID, + UserID: u.ID, + Roles: []string{rbac.RoleOrgAdmin()}, + }) + + check.Args(database.PaginatedOrganizationMembersParams{ + OrganizationID: uuid.UUID{}, + LimitOpt: 1, + }).Asserts( + mem, policy.ActionRead, + ) + })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 5ad0f23572e03..07e3c38212bf3 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9590,7 +9590,37 @@ func (q *FakeQuerier) PaginatedOrganizationMembers(ctx context.Context, arg data return nil, err } - panic("not implemented") + q.mutex.RLock() + defer q.mutex.RUnlock() + + tmp := make([]database.PaginatedOrganizationMembersRow, 0) + + skippedMembers := 0 + for _, organizationMember := range q.organizationMembers { + if arg.OrganizationID != uuid.Nil && organizationMember.OrganizationID != arg.OrganizationID { + continue + } + + if skippedMembers < int(arg.OffsetOpt) { + skippedMembers += 1 + continue + } + + if len(tmp) >= int(arg.LimitOpt) { + break + } + + user, _ := q.getUserByIDNoLock(organizationMember.UserID) + tmp = append(tmp, database.PaginatedOrganizationMembersRow{ + OrganizationMember: organizationMember, + Username: user.Username, + AvatarURL: user.AvatarURL, + Name: user.Name, + Email: user.Email, + GlobalRoles: user.RBACRoles, + }) + } + return tmp, nil } func (q *FakeQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(_ context.Context, templateID uuid.UUID) error { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index fe782bdd14170..a9dbc3e530994 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -256,6 +256,10 @@ func (m OrganizationMembersRow) RBACObject() rbac.Object { return m.OrganizationMember.RBACObject() } +func (m PaginatedOrganizationMembersRow) RBACObject() rbac.Object { + return m.OrganizationMember.RBACObject() +} + func (m GetOrganizationIDsByMemberIDsRow) RBACObject() rbac.Object { // TODO: This feels incorrect as we are really returning a list of orgmembers. // This return type should be refactored to return a list of orgmembers, not this From ef40967960953e7d36bbeec48abaeae2c6f0d667 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 22:08:05 +0000 Subject: [PATCH 05/18] feat: implement endpoint --- coderd/members.go | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/coderd/members.go b/coderd/members.go index 29722834dbe07..3a2ddc5ea2cfc 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -187,9 +187,49 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Success 200 {object} []codersdk.OrganizationMemberWithUserData // @Router /organizations/{organization}/paginated-members [get] func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + paginationParams, ok = parsePagination(rw, r) + ) + if !ok { + return + } - resp := codersdk.PaginatedMembersResponse{} + paginatedMemberRows, err := api.Database.PaginatedOrganizationMembers(ctx, database.PaginatedOrganizationMembersParams{ + OrganizationID: organization.ID, + LimitOpt: int32(paginationParams.Limit), + OffsetOpt: int32(paginationParams.Offset), + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + memberRows := make([]database.OrganizationMembersRow, len(paginatedMemberRows)) + for _, pRow := range paginatedMemberRows { + row := database.OrganizationMembersRow{ + OrganizationMember: pRow.OrganizationMember, + Username: pRow.Username, + AvatarURL: pRow.AvatarURL, + Name: pRow.Name, + Email: pRow.Email, + GlobalRoles: pRow.GlobalRoles, + } + + memberRows = append(memberRows, row) + } + + members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows) + + resp := codersdk.PaginatedMembersResponse{ + Members: members, + Count: int(paginatedMemberRows[0].Count), + } httpapi.Write(ctx, rw, http.StatusOK, resp) } From 6aaddaf58b48a0a4b0143d46b76970838b1218b1 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 22:15:55 +0000 Subject: [PATCH 06/18] fix: correct endpoint documentation --- coderd/members.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/coderd/members.go b/coderd/members.go index 3a2ddc5ea2cfc..d71eb69d08a75 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -184,7 +184,9 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Members // @Param organization path string true "Organization ID" -// @Success 200 {object} []codersdk.OrganizationMemberWithUserData +// @Param limit query int false "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} []codersdk.codersdk.PaginatedMembersResponse // @Router /organizations/{organization}/paginated-members [get] func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { var ( From a724f484c93e201e8d6ae95b5f9bf2b330971c4b Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 22:25:00 +0000 Subject: [PATCH 07/18] fix: lint errors --- coderd/database/dbmem/dbmem.go | 4 ++-- coderd/members.go | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 07e3c38212bf3..d0329e3c3444f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9584,7 +9584,7 @@ func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.Organi return tmp, nil } -func (q *FakeQuerier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { +func (q *FakeQuerier) PaginatedOrganizationMembers(_ context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err @@ -9602,7 +9602,7 @@ func (q *FakeQuerier) PaginatedOrganizationMembers(ctx context.Context, arg data } if skippedMembers < int(arg.OffsetOpt) { - skippedMembers += 1 + skippedMembers++ continue } diff --git a/coderd/members.go b/coderd/members.go index d71eb69d08a75..977b23a867054 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -212,7 +212,7 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { return } - memberRows := make([]database.OrganizationMembersRow, len(paginatedMemberRows)) + memberRows := make([]database.OrganizationMembersRow, 0) for _, pRow := range paginatedMemberRows { row := database.OrganizationMembersRow{ OrganizationMember: pRow.OrganizationMember, @@ -227,6 +227,9 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { } members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows) + if err != nil { + httpapi.InternalServerError(rw, err) + } resp := codersdk.PaginatedMembersResponse{ Members: members, From 49255925dabccce0ad463c91a70f252f3b1f53b5 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 22:40:11 +0000 Subject: [PATCH 08/18] fix: generated docs from new endpoint --- coderd/apidoc/docs.go | 28 ++++++++++- coderd/apidoc/swagger.json | 28 ++++++++++- coderd/members.go | 2 +- docs/reference/api/members.md | 91 +++++++++++++++++++---------------- docs/reference/api/schemas.md | 41 ++++++++++++++++ 5 files changed, 146 insertions(+), 44 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 15b087f819e2e..26ecfdbc70180 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2993,6 +2993,18 @@ const docTemplate = `{ "name": "organization", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" } ], "responses": { @@ -3001,7 +3013,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + "$ref": "#/definitions/codersdk.PaginatedMembersResponse" } } } @@ -12939,6 +12951,20 @@ const docTemplate = `{ } } }, + "codersdk.PaginatedMembersResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + } + } + } + }, "codersdk.PatchGroupIDPSyncConfigRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index df1f58359fced..8b9f538735c99 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2625,6 +2625,18 @@ "name": "organization", "in": "path", "required": true + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" } ], "responses": { @@ -2633,7 +2645,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + "$ref": "#/definitions/codersdk.PaginatedMembersResponse" } } } @@ -11662,6 +11674,20 @@ } } }, + "codersdk.PaginatedMembersResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" + } + } + } + }, "codersdk.PatchGroupIDPSyncConfigRequest": { "type": "object", "properties": { diff --git a/coderd/members.go b/coderd/members.go index 977b23a867054..0b2fd7b5757b8 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -186,7 +186,7 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Param organization path string true "Organization ID" // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" -// @Success 200 {object} []codersdk.codersdk.PaginatedMembersResponse +// @Success 200 {object} []codersdk.PaginatedMembersResponse // @Router /organizations/{organization}/paginated-members [get] func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { var ( diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 11b0f1c50cc86..882323083a2ce 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -828,9 +828,11 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat ### Parameters -| Name | In | Type | Required | Description | -|----------------|------|--------|----------|-----------------| -| `organization` | path | string | true | Organization ID | +| Name | In | Type | Required | Description | +|----------------|-------|---------|----------|-----------------| +| `organization` | path | string | true | Organization ID | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | ### Example responses @@ -839,58 +841,65 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat ```json [ { - "avatar_url": "string", - "created_at": "2019-08-24T14:15:22Z", - "email": "string", - "global_roles": [ + "count": 0, + "members": [ { - "display_name": "string", + "avatar_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "email": "string", + "global_roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], "name": "string", - "organization_id": "string" + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } - ], - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "updated_at": "2019-08-24T14:15:22Z", - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", - "username": "string" + ] } ] ``` ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OrganizationMemberWithUserData](schemas.md#codersdkorganizationmemberwithuserdata) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.PaginatedMembersResponse](schemas.md#codersdkpaginatedmembersresponse) |

Response Schema

Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» created_at` | string(date-time) | false | | | -| `» email` | string | false | | | -| `» global_roles` | array | false | | | -| `»» display_name` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string | false | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» roles` | array | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» user_id` | string(uuid) | false | | | -| `» username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» count` | integer | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» email` | string | false | | | +| `»» global_roles` | array | false | | | +| `»»» display_name` | string | false | | | +| `»»» name` | string | false | | | +| `»»» organization_id` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» roles` | array | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `»» user_id` | string(uuid) | false | | | +| `»» username` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9fa22af7356ae..42ef8a7ade184 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4189,6 +4189,47 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | » `[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.PaginatedMembersResponse + +```json +{ + "count": 0, + "members": [ + { + "avatar_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "email": "string", + "global_roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|---------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `members` | array of [codersdk.OrganizationMemberWithUserData](#codersdkorganizationmemberwithuserdata) | false | | | + ## codersdk.PatchGroupIDPSyncConfigRequest ```json From 902538c5c387f8fe05cc8d801ba4a6a16540991d Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Thu, 6 Mar 2025 22:44:47 +0000 Subject: [PATCH 09/18] fix: remove unneeded comments --- codersdk/organizations.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 2c1f9dc986b7c..e093f6f85594a 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -82,19 +82,14 @@ type OrganizationMemberWithUserData struct { } type PaginatedMembersRequest struct { - // OrganizationID uuid.UUID `table:"organization id" json:"organization_id" format:"uuid"` - // - Limit int `json:"limit,omitempty"` - // - Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` } type PaginatedMembersResponse struct { - // Members []OrganizationMemberWithUserData - // - Count int `json:"count"` + Count int `json:"count"` } type CreateOrganizationRequest struct { From 06ff95c196c94d000f85840757128fd72a9f0744 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Fri, 7 Mar 2025 20:46:50 +0000 Subject: [PATCH 10/18] wip --- coderd/database/dbauthz/dbauthz.go | 5 ++++- coderd/database/dbauthz/dbauthz_test.go | 10 ++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d567285a47219..19e10ca0f1f78 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3582,7 +3582,10 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza } func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.PaginatedOrganizationMembers)(ctx, arg) + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID)); err != nil { + return nil, err + } + return q.db.PaginatedOrganizationMembers(ctx, arg) } func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 575744727ae60..ed82aa7625581 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -987,18 +987,12 @@ func (s *MethodTestSuite) TestOrganization() { })) s.Run("PaginatedOrganizationMembers", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ - OrganizationID: o.ID, - UserID: u.ID, - Roles: []string{rbac.RoleOrgAdmin()}, - }) check.Args(database.PaginatedOrganizationMembersParams{ - OrganizationID: uuid.UUID{}, + OrganizationID: o.ID, LimitOpt: 1, }).Asserts( - mem, policy.ActionRead, + rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, ) })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { From 76a4cbf480b43babbfca9143a5b38f963de7fc16 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Fri, 7 Mar 2025 20:54:08 +0000 Subject: [PATCH 11/18] fix: incorrectly showing type in panic --- coderd/database/dbauthz/setup_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 4faac05b4746e..1a822254a9e7a 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -503,7 +503,7 @@ func asserts(inputs ...any) []AssertRBAC { // Could be the string type. actionAsString, ok := inputs[i+1].(string) if !ok { - panic(fmt.Sprintf("action '%q' not a supported action", actionAsString)) + panic(fmt.Sprintf("action '%T' not a supported action", inputs[i+1])) } action = policy.Action(actionAsString) } From 737ac9ff89e4ecfbfe238dd964b0e473749daa53 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Fri, 7 Mar 2025 21:56:48 +0000 Subject: [PATCH 12/18] fix: update docs --- coderd/apidoc/docs.go | 3 ++- coderd/apidoc/swagger.json | 3 ++- coderd/database/dbauthz/dbauthz_test.go | 16 +++++++++++++++- coderd/members.go | 3 ++- docs/reference/api/members.md | 10 +++++----- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 26ecfdbc70180..0fd3d1165ed8e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2545,6 +2545,7 @@ const docTemplate = `{ ], "summary": "List organization members", "operationId": "list-organization-members", + "deprecated": true, "parameters": [ { "type": "string", @@ -2996,7 +2997,7 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Page limit", + "description": "Page limit, if 0 returns all members", "name": "limit", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8b9f538735c99..21546acb32ab3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2223,6 +2223,7 @@ "tags": ["Members"], "summary": "List organization members", "operationId": "list-organization-members", + "deprecated": true, "parameters": [ { "type": "string", @@ -2628,7 +2629,7 @@ }, { "type": "integer", - "description": "Page limit", + "description": "Page limit, if 0 returns all members", "name": "limit", "in": "query" }, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ed82aa7625581..c12b519fc98ee 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -987,13 +987,27 @@ func (s *MethodTestSuite) TestOrganization() { })) s.Run("PaginatedOrganizationMembers", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ + OrganizationID: o.ID, + UserID: u.ID, + Roles: []string{rbac.RoleOrgAdmin()}, + }) check.Args(database.PaginatedOrganizationMembersParams{ OrganizationID: o.ID, LimitOpt: 1, }).Asserts( rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, - ) + ).Returns([]database.PaginatedOrganizationMembersRow{ + {OrganizationMember: mem, + Username: u.Username, + AvatarURL: u.AvatarURL, + Name: u.Name, + Email: u.Email, + GlobalRoles: u.RBACRoles, + Count: 1, + }}) })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) diff --git a/coderd/members.go b/coderd/members.go index 0b2fd7b5757b8..1852e6448408f 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -142,6 +142,7 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request rw.WriteHeader(http.StatusNoContent) } +// @Deprecated use /organizations/{organization}/paginated-members [get] // @Summary List organization members // @ID list-organization-members // @Security CoderSessionToken @@ -184,7 +185,7 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Members // @Param organization path string true "Organization ID" -// @Param limit query int false "Page limit" +// @Param limit query int false "Page limit, if 0 returns all members" // @Param offset query int false "Page offset" // @Success 200 {object} []codersdk.PaginatedMembersResponse // @Router /organizations/{organization}/paginated-members [get] diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 882323083a2ce..fd075f9f0d550 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -828,11 +828,11 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat ### Parameters -| Name | In | Type | Required | Description | -|----------------|-------|---------|----------|-----------------| -| `organization` | path | string | true | Organization ID | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------------|-------|---------|----------|--------------------------------------| +| `organization` | path | string | true | Organization ID | +| `limit` | query | integer | false | Page limit, if 0 returns all members | +| `offset` | query | integer | false | Page offset | ### Example responses From 8910df3465db7ebfbe1b397a83ca14ba0ca5b388 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Fri, 7 Mar 2025 21:59:25 +0000 Subject: [PATCH 13/18] fix: change dbauthz test back to proper one --- coderd/database/dbauthz/dbauthz_test.go | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c12b519fc98ee..ed82aa7625581 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -987,27 +987,13 @@ func (s *MethodTestSuite) TestOrganization() { })) s.Run("PaginatedOrganizationMembers", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ - OrganizationID: o.ID, - UserID: u.ID, - Roles: []string{rbac.RoleOrgAdmin()}, - }) check.Args(database.PaginatedOrganizationMembersParams{ OrganizationID: o.ID, LimitOpt: 1, }).Asserts( rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, - ).Returns([]database.PaginatedOrganizationMembersRow{ - {OrganizationMember: mem, - Username: u.Username, - AvatarURL: u.AvatarURL, - Name: u.Name, - Email: u.Email, - GlobalRoles: u.RBACRoles, - Count: 1, - }}) + ) })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) From 0e579a22b9fd5b78de2c4545fc941623fadbe21c Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Fri, 7 Mar 2025 22:05:42 +0000 Subject: [PATCH 14/18] fix: un-nest route --- coderd/coderd.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index ec8499ab86e60..da4e281dbe506 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1002,9 +1002,7 @@ func New(options *Options) *API { }) }) }) - r.Route("/paginated-members", func(r chi.Router) { - r.Get("/", api.paginatedMembers) - }) + r.Get("/paginated-members", api.paginatedMembers) r.Route("/members", func(r chi.Router) { r.Get("/", api.listMembers) r.Route("/roles", func(r chi.Router) { From 73e356c4fc889fac6e684294982fb2935d3b7b89 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Mon, 10 Mar 2025 16:00:56 +0000 Subject: [PATCH 15/18] fix: add comment --- coderd/database/dbauthz/dbauthz.go | 1 + coderd/database/dbauthz/dbauthz_test.go | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 19e10ca0f1f78..9c88e986cbffc 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3582,6 +3582,7 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza } func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { + // Required to have permission to read all members in the organization if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID)); err != nil { return nil, err } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ed82aa7625581..7c57c3b717ab0 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -987,6 +987,12 @@ func (s *MethodTestSuite) TestOrganization() { })) s.Run("PaginatedOrganizationMembers", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ + OrganizationID: o.ID, + UserID: u.ID, + Roles: []string{rbac.RoleOrgAdmin()}, + }) check.Args(database.PaginatedOrganizationMembersParams{ OrganizationID: o.ID, From 24332f4c6804635d0ab784a047113d469db95a09 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Mon, 10 Mar 2025 16:01:58 +0000 Subject: [PATCH 16/18] fix: update test to account for dbmem and check for returned member --- coderd/database/dbauthz/dbauthz_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 7c57c3b717ab0..558b6d46a3b2d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -996,10 +996,10 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(database.PaginatedOrganizationMembersParams{ OrganizationID: o.ID, - LimitOpt: 1, + LimitOpt: 0, }).Asserts( rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, - ) + ).Returns(mem) })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) From 7af0b606df885e5ed73f08f0b57257988468409a Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Mon, 10 Mar 2025 16:08:57 +0000 Subject: [PATCH 17/18] fix: update test to check for accurate count --- coderd/database/dbauthz/dbauthz_test.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 558b6d46a3b2d..ec8ced783fa0a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -999,7 +999,17 @@ func (s *MethodTestSuite) TestOrganization() { LimitOpt: 0, }).Asserts( rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, - ).Returns(mem) + ).Returns([]database.PaginatedOrganizationMembersRow{ + { + OrganizationMember: mem, + Username: u.Username, + AvatarURL: u.AvatarURL, + Name: u.Name, + Email: u.Email, + GlobalRoles: u.RBACRoles, + Count: 1, + }, + }) })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) From cdc03224adfc45813995705889c428855ad41fc5 Mon Sep 17 00:00:00 2001 From: Brett Kolodny Date: Mon, 10 Mar 2025 16:26:20 +0000 Subject: [PATCH 18/18] fix: make dbmem implementation match expected behaior around 0 limit --- coderd/database/dbmem/dbmem.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d0329e3c3444f..63ee1d0bd95e7 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9593,34 +9593,42 @@ func (q *FakeQuerier) PaginatedOrganizationMembers(_ context.Context, arg databa q.mutex.RLock() defer q.mutex.RUnlock() - tmp := make([]database.PaginatedOrganizationMembersRow, 0) - - skippedMembers := 0 - for _, organizationMember := range q.organizationMembers { - if arg.OrganizationID != uuid.Nil && organizationMember.OrganizationID != arg.OrganizationID { + // All of the members in the organization + orgMembers := make([]database.OrganizationMember, 0) + for _, mem := range q.organizationMembers { + if arg.OrganizationID != uuid.Nil && mem.OrganizationID != arg.OrganizationID { continue } + orgMembers = append(orgMembers, mem) + } + + selectedMembers := make([]database.PaginatedOrganizationMembersRow, 0) + + skippedMembers := 0 + for _, organizationMember := range q.organizationMembers { if skippedMembers < int(arg.OffsetOpt) { skippedMembers++ continue } - if len(tmp) >= int(arg.LimitOpt) { + // if the limit is set to 0 we treat that as returning all of the org members + if int(arg.LimitOpt) != 0 && len(selectedMembers) >= int(arg.LimitOpt) { break } user, _ := q.getUserByIDNoLock(organizationMember.UserID) - tmp = append(tmp, database.PaginatedOrganizationMembersRow{ + selectedMembers = append(selectedMembers, database.PaginatedOrganizationMembersRow{ OrganizationMember: organizationMember, Username: user.Username, AvatarURL: user.AvatarURL, Name: user.Name, Email: user.Email, GlobalRoles: user.RBACRoles, + Count: int64(len(orgMembers)), }) } - return tmp, nil + return selectedMembers, nil } func (q *FakeQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(_ context.Context, templateID uuid.UUID) error {