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
+11
View File
@@ -81,6 +81,17 @@ 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.
+90
View File
@@ -813,6 +813,96 @@ 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 |
| `limit` | query | integer | false | Page limit, if 0 returns all members |
| `offset` | query | integer | false | Page offset |
### Example responses
> 200 Response
```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"
}
]
}
]
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.PaginatedMembersResponse](schemas.md#codersdkpaginatedmembersresponse) |
<h3 id="paginated-organization-members-responseschema">Response Schema</h3>
Status Code **200**
| 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).
## Get site member roles
### Code samples
+41
View File
@@ -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
+13
View File
@@ -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;