feat: add a paginated organization members endpoint (#16835)

Closes
[coder/internal#460](https://github.com/coder/internal/issues/460)
This commit is contained in:
brettkolodny
2025-03-10 14:42:07 -04:00
committed by GitHub
parent 191b0efb80
commit 8c0350e20c
18 changed files with 548 additions and 1 deletions
+64
View File
@@ -2545,6 +2545,7 @@ const docTemplate = `{
],
"summary": "List organization members",
"operationId": "list-organization-members",
"deprecated": true,
"parameters": [
{
"type": "string",
@@ -2971,6 +2972,55 @@ 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
},
{
"type": "integer",
"description": "Page limit, if 0 returns all members",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PaginatedMembersResponse"
}
}
}
}
}
},
"/organizations/{organization}/provisionerdaemons": {
"get": {
"security": [
@@ -12902,6 +12952,20 @@ const docTemplate = `{
}
}
},
"codersdk.PaginatedMembersResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"members": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OrganizationMemberWithUserData"
}
}
}
},
"codersdk.PatchGroupIDPSyncConfigRequest": {
"type": "object",
"properties": {
+60
View File
@@ -2223,6 +2223,7 @@
"tags": ["Members"],
"summary": "List organization members",
"operationId": "list-organization-members",
"deprecated": true,
"parameters": [
{
"type": "string",
@@ -2607,6 +2608,51 @@
}
}
},
"/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
},
{
"type": "integer",
"description": "Page limit, if 0 returns all members",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.PaginatedMembersResponse"
}
}
}
}
}
},
"/organizations/{organization}/provisionerdaemons": {
"get": {
"security": [
@@ -11629,6 +11675,20 @@
}
}
},
"codersdk.PaginatedMembersResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"members": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OrganizationMemberWithUserData"
}
}
}
},
"codersdk.PatchGroupIDPSyncConfigRequest": {
"type": "object",
"properties": {
+1
View File
@@ -1002,6 +1002,7 @@ func New(options *Options) *API {
})
})
})
r.Get("/paginated-members", api.paginatedMembers)
r.Route("/members", func(r chi.Router) {
r.Get("/", api.listMembers)
r.Route("/roles", func(r chi.Router) {
+8
View File
@@ -3581,6 +3581,14 @@ 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) {
// 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
}
return q.db.PaginatedOrganizationMembers(ctx, arg)
}
func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
template, err := q.db.GetTemplateByID(ctx, templateID)
if err != nil {
+26
View File
@@ -985,6 +985,32 @@ 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: o.ID,
LimitOpt: 0,
}).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{})
u := dbgen.User(s.T(), db, database.User{})
+1 -1
View File
@@ -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)
}
+47
View File
@@ -9584,6 +9584,53 @@ func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.Organi
return tmp, nil
}
func (q *FakeQuerier) PaginatedOrganizationMembers(_ context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
q.mutex.RLock()
defer q.mutex.RUnlock()
// 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 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)
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 selectedMembers, nil
}
func (q *FakeQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(_ context.Context, templateID uuid.UUID) error {
err := validateDatabaseType(templateID)
if err != nil {
@@ -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)
+15
View File
@@ -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()
+4
View File
@@ -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
+1
View File
@@ -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
+75
View File
@@ -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
@@ -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);
+61
View File
@@ -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
@@ -178,6 +179,66 @@ 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"
// @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]
func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
organization = httpmw.OrganizationParam(r)
paginationParams, ok = parsePagination(rw, r)
)
if !ok {
return
}
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, 0)
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)
if err != nil {
httpapi.InternalServerError(rw, err)
}
resp := codersdk.PaginatedMembersResponse{
Members: members,
Count: int(paginatedMemberRows[0].Count),
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Assign role to organization member
// @ID assign-role-to-organization-member
// @Security CoderSessionToken