mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user