mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add a paginated organization members endpoint (#16835)
Closes [coder/internal#460](https://github.com/coder/internal/issues/460)
This commit is contained in:
Generated
+64
@@ -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": {
|
||||
|
||||
Generated
+60
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user