From 24ab216dd1369aa06d70cebda0605f18008e0b7c Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 20 Mar 2026 12:43:03 -0800 Subject: [PATCH] feat: add new group members endpoint with filtering and pagination (#23067) Partially addresses #21813 (still need to make changes to the "add user" button to be complete) Since there are a lot of user tests already, I moved them into `coderdtest` to be shared. --- coderd/apidoc/docs.go | 146 +++++ coderd/apidoc/swagger.json | 138 +++++ coderd/coderdtest/coderdtest.go | 9 +- coderd/coderdtest/users.go | 584 ++++++++++++++++++ coderd/database/db2sdk/db2sdk.go | 28 + coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dbauthz/dbauthz_test.go | 9 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 + coderd/database/modelmethods.go | 4 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 206 +++++++ coderd/database/queries/groupmembers.sql | 104 ++++ coderd/users_test.go | 700 +--------------------- codersdk/groups.go | 41 +- codersdk/users.go | 52 +- docs/reference/api/enterprise.md | 122 +++- docs/reference/api/schemas.md | 31 + enterprise/coderd/coderd.go | 2 + enterprise/coderd/groups.go | 111 +++- enterprise/coderd/groups_test.go | 140 ++++- site/src/api/typesGenerated.ts | 11 + 22 files changed, 1742 insertions(+), 724 deletions(-) create mode 100644 coderd/coderdtest/users.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2e8809b248..22e7e3feeb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1520,6 +1520,12 @@ const docTemplate = `{ "name": "group", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "Exclude members from the response", + "name": "exclude_members", + "in": "query" } ], "responses": { @@ -1613,6 +1619,65 @@ const docTemplate = `{ ] } }, + "/groups/{group}/members": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get group members by group ID", + "operationId": "get-group-members-by-group-id", + "parameters": [ + { + "type": "string", + "description": "Group id", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GroupMembersResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/init-script/{os}/{arch}": { "get": { "produces": [ @@ -3388,6 +3453,73 @@ const docTemplate = `{ ] } }, + "/organizations/{organization}/groups/{groupName}/members": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get group members by organization and group name", + "operationId": "get-group-members-by-organization-and-group-name", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group name", + "name": "groupName", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GroupMembersResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/organizations/{organization}/members": { "get": { "produces": [ @@ -15749,6 +15881,20 @@ const docTemplate = `{ } } }, + "codersdk.GroupMembersResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + } + } + }, "codersdk.GroupSource": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ffe6de5e8c..569adac412 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1323,6 +1323,12 @@ "name": "group", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "Exclude members from the response", + "name": "exclude_members", + "in": "query" } ], "responses": { @@ -1406,6 +1412,61 @@ ] } }, + "/groups/{group}/members": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get group members by group ID", + "operationId": "get-group-members-by-group-id", + "parameters": [ + { + "type": "string", + "description": "Group id", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GroupMembersResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/init-script/{os}/{arch}": { "get": { "produces": ["text/plain"], @@ -2975,6 +3036,69 @@ ] } }, + "/organizations/{organization}/groups/{groupName}/members": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get group members by organization and group name", + "operationId": "get-group-members-by-organization-and-group-name", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Group name", + "name": "groupName", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Member search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GroupMembersResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/organizations/{organization}/members": { "get": { "produces": ["application/json"], @@ -14257,6 +14381,20 @@ } } }, + "codersdk.GroupMembersResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + } + } + }, "codersdk.GroupSource": { "type": "string", "enum": ["user", "oidc"], diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index c0a0777ddc..43ed9b9569 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -900,9 +900,10 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI require.NoError(t, err) var sessionToken string - if req.UserLoginType == codersdk.LoginTypeNone { - // Cannot log in with a disabled login user. So make it an api key from - // the client making this user. + switch req.UserLoginType { + case codersdk.LoginTypeNone, codersdk.LoginTypeGithub, codersdk.LoginTypeOIDC: + // Cannot log in with a non-password user. So make it an api key from the + // client making this user. token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{ Lifetime: time.Hour * 24, Scope: codersdk.APIKeyScopeAll, @@ -910,7 +911,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI }) require.NoError(t, err) sessionToken = token.Key - } else { + default: login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ Email: req.Email, Password: req.Password, diff --git a/coderd/coderdtest/users.go b/coderd/coderdtest/users.go new file mode 100644 index 0000000000..2732c49673 --- /dev/null +++ b/coderd/coderdtest/users.go @@ -0,0 +1,584 @@ +package coderdtest + +import ( + "context" + "database/sql" + "fmt" + "slices" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// UsersPagination creates a set of users for testing pagination. It can be +// used to test paginating both users and group members. +func UsersPagination( + ctx context.Context, + t *testing.T, + client *codersdk.Client, + setup func(users []codersdk.User), + fetch func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int), +) { + t.Helper() + + firstUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err, "fetch me") + + count := 10 + users := make([]codersdk.User, count) + orgID := firstUser.OrganizationIDs[0] + users[0] = firstUser + for i := range count - 1 { + _, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + if i < 5 { + r.Name = fmt.Sprintf("before%d", i) + } else { + r.Name = fmt.Sprintf("after%d", i) + } + }) + users[i+1] = user + } + + slices.SortFunc(users, func(a, b codersdk.User) int { + return slice.Ascending(strings.ToLower(a.Username), strings.ToLower(b.Username)) + }) + + if setup != nil { + setup(users) + } + + gotUsers, gotCount := fetch(codersdk.UsersRequest{}) + require.Len(t, gotUsers, count) + require.Equal(t, gotCount, count) + + gotUsers, gotCount = fetch(codersdk.UsersRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.Len(t, gotUsers, 1) + require.Equal(t, gotCount, count) + + gotUsers, gotCount = fetch(codersdk.UsersRequest{ + Pagination: codersdk.Pagination{ + Offset: 1, + }, + }) + require.Len(t, gotUsers, count-1) + require.Equal(t, gotCount, count) + + gotUsers, gotCount = fetch(codersdk.UsersRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + Offset: 1, + }, + }) + require.Len(t, gotUsers, 1) + require.Equal(t, gotCount, count) + + // If offset is higher than the count postgres returns an empty array + // and not an ErrNoRows error. + gotUsers, gotCount = fetch(codersdk.UsersRequest{ + Pagination: codersdk.Pagination{ + Offset: count + 1, + }, + }) + require.Len(t, gotUsers, 0) + require.Equal(t, gotCount, 0) + + // Check that AfterID works. + gotUsers, gotCount = fetch(codersdk.UsersRequest{ + Pagination: codersdk.Pagination{ + AfterID: users[5].ID, + }, + }) + require.NoError(t, err) + require.Len(t, gotUsers, 4) + require.Equal(t, gotCount, 4) + + // Check we can paginate a filtered response. + gotUsers, gotCount = fetch(codersdk.UsersRequest{ + SearchQuery: "name:after", + Pagination: codersdk.Pagination{ + Limit: 1, + Offset: 1, + }, + }) + require.NoError(t, err) + require.Len(t, gotUsers, 1) + require.Equal(t, gotCount, 4) + require.Contains(t, gotUsers[0].Name, "after") +} + +// UsersFilter creates a set of users to run various filters against for +// testing. It can be used to test filtering both users and group members. +func UsersFilter( + setupCtx context.Context, + t *testing.T, + client *codersdk.Client, + db database.Store, + setup func(users []codersdk.User), + fetch func(ctx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser, +) { + t.Helper() + + firstUser, err := client.User(setupCtx, codersdk.Me) + require.NoError(t, err, "fetch me") + + // Noon on Jan 18 is the "now" for this test for last_seen timestamps. + // All these values are equal + // 2023-01-18T12:00:00Z (UTC) + // 2023-01-18T07:00:00-05:00 (America/New_York) + // 2023-01-18T13:00:00+01:00 (Europe/Madrid) + // 2023-01-16T00:00:00+12:00 (Asia/Anadyr) + lastSeenNow := time.Date(2023, 1, 18, 12, 0, 0, 0, time.UTC) + users := make([]codersdk.User, 0) + users = append(users, firstUser) + orgID := firstUser.OrganizationIDs[0] + for i := range 15 { + roles := []rbac.RoleIdentifier{} + if i%2 == 0 { + roles = append(roles, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()) + } + if i%3 == 0 { + roles = append(roles, rbac.RoleAuditor()) + } + userClient, userData := CreateAnotherUserMutators(t, client, orgID, roles, func(r *codersdk.CreateUserRequestWithOrgs) { + switch { + case i%7 == 0: + r.Username += fmt.Sprintf("-gh%d", i) + r.UserLoginType = codersdk.LoginTypeGithub + r.Password = "" + case i%6 == 0: + r.UserLoginType = codersdk.LoginTypeOIDC + r.Password = "" + default: + r.UserLoginType = codersdk.LoginTypePassword + } + }) + + // Set the last seen for each user to a unique day + // nolint:gocritic // Setting up unit test data. + _, err := db.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(setupCtx), database.UpdateUserLastSeenAtParams{ + ID: userData.ID, + LastSeenAt: lastSeenNow.Add(-1 * time.Hour * 24 * time.Duration(i)), + UpdatedAt: time.Now(), + }) + require.NoError(t, err, "set a last seen") + + // Set a github user ID for github login types. + if i%7 == 0 { + // nolint:gocritic // Setting up unit test data. + err = db.UpdateUserGithubComUserID(dbauthz.AsSystemRestricted(setupCtx), database.UpdateUserGithubComUserIDParams{ + ID: userData.ID, + GithubComUserID: sql.NullInt64{ + Int64: int64(i), + Valid: true, + }, + }) + require.NoError(t, err) + } + + user, err := userClient.User(setupCtx, codersdk.Me) + require.NoError(t, err, "fetch me") + + if i%4 == 0 { + user, err = client.UpdateUserStatus(setupCtx, user.ID.String(), codersdk.UserStatusSuspended) + require.NoError(t, err, "suspend user") + } + + if i%5 == 0 { + user, err = client.UpdateUserProfile(setupCtx, user.ID.String(), codersdk.UpdateUserProfileRequest{ + Username: strings.ToUpper(user.Username), + }) + require.NoError(t, err, "update username to uppercase") + } + + users = append(users, user) + } + + hashedPassword, err := userpassword.Hash("SomeStrongPassword!") + require.NoError(t, err) + + // Add users with different creation dates for testing date filters + for i := range 3 { + // nolint:gocritic // Setting up unit test data. + user1, err := db.InsertUser(dbauthz.AsSystemRestricted(setupCtx), database.InsertUserParams{ + ID: uuid.New(), + Email: fmt.Sprintf("before%d@coder.com", i), + Username: fmt.Sprintf("before%d", i), + Name: fmt.Sprintf("Test User %d", i), + HashedPassword: []byte(hashedPassword), + LoginType: database.LoginTypeNone, + Status: string(codersdk.UserStatusActive), + RBACRoles: []string{codersdk.RoleMember}, + CreatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)), + UpdatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)), + IsServiceAccount: false, + }) + require.NoError(t, err) + // nolint:gocritic // Setting up unit test data. + _, err = db.InsertOrganizationMember(dbauthz.AsSystemRestricted(setupCtx), database.InsertOrganizationMemberParams{ + OrganizationID: orgID, + UserID: user1.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: []string{}, + }) + require.NoError(t, err) + + // The expected timestamps must be parsed from strings to compare equal during `ElementsMatch` + sdkUser1 := db2sdk.User(user1, []uuid.UUID{orgID}) + sdkUser1.CreatedAt, err = time.Parse(time.RFC3339, sdkUser1.CreatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser1.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser1.UpdatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser1.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser1.LastSeenAt.Format(time.RFC3339)) + require.NoError(t, err) + users = append(users, sdkUser1) + + // nolint:gocritic // Setting up unit test data. + user2, err := db.InsertUser(dbauthz.AsSystemRestricted(setupCtx), database.InsertUserParams{ + ID: uuid.New(), + Email: fmt.Sprintf("during%d@coder.com", i), + Username: fmt.Sprintf("during%d", i), + Name: "", + HashedPassword: []byte(hashedPassword), + LoginType: database.LoginTypeNone, + Status: string(codersdk.UserStatusActive), + RBACRoles: []string{codersdk.RoleOwner}, + CreatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)), + UpdatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)), + IsServiceAccount: false, + }) + require.NoError(t, err) + // nolint:gocritic // Setting up unit test data. + _, err = db.InsertOrganizationMember(dbauthz.AsSystemRestricted(setupCtx), database.InsertOrganizationMemberParams{ + OrganizationID: orgID, + UserID: user2.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: []string{}, + }) + require.NoError(t, err) + + sdkUser2 := db2sdk.User(user2, []uuid.UUID{orgID}) + sdkUser2.CreatedAt, err = time.Parse(time.RFC3339, sdkUser2.CreatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser2.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser2.UpdatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser2.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser2.LastSeenAt.Format(time.RFC3339)) + require.NoError(t, err) + users = append(users, sdkUser2) + + // nolint:gocritic // Setting up unit test data. + user3, err := db.InsertUser(dbauthz.AsSystemRestricted(setupCtx), database.InsertUserParams{ + ID: uuid.New(), + Email: fmt.Sprintf("after%d@coder.com", i), + Username: fmt.Sprintf("after%d", i), + Name: "", + HashedPassword: []byte(hashedPassword), + LoginType: database.LoginTypeNone, + Status: string(codersdk.UserStatusActive), + RBACRoles: []string{codersdk.RoleOwner}, + CreatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)), + UpdatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)), + IsServiceAccount: false, + }) + require.NoError(t, err) + // nolint:gocritic // Setting up unit test data. + _, err = db.InsertOrganizationMember(dbauthz.AsSystemRestricted(setupCtx), database.InsertOrganizationMemberParams{ + OrganizationID: orgID, + UserID: user3.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: []string{}, + }) + require.NoError(t, err) + + sdkUser3 := db2sdk.User(user3, []uuid.UUID{orgID}) + sdkUser3.CreatedAt, err = time.Parse(time.RFC3339, sdkUser3.CreatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser3.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser3.UpdatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser3.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser3.LastSeenAt.Format(time.RFC3339)) + require.NoError(t, err) + users = append(users, sdkUser3) + } + + if setup != nil { + setup(users) + } + + // --- Setup done --- + testCases := []struct { + Name string + Filter codersdk.UsersRequest + // If FilterF is true, we include it in the expected results + FilterF func(f codersdk.UsersRequest, user codersdk.User) bool + }{ + { + Name: "All", + Filter: codersdk.UsersRequest{ + Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, + }, + FilterF: func(_ codersdk.UsersRequest, _ codersdk.User) bool { + return true + }, + }, + { + Name: "Active", + Filter: codersdk.UsersRequest{ + Status: codersdk.UserStatusActive, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.Status == codersdk.UserStatusActive + }, + }, + { + Name: "GithubComUserID", + Filter: codersdk.UsersRequest{ + SearchQuery: "github_com_user_id:7", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return strings.HasSuffix(u.Username, "-gh7") + }, + }, + { + Name: "ActiveUppercase", + Filter: codersdk.UsersRequest{ + Status: "ACTIVE", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.Status == codersdk.UserStatusActive + }, + }, + { + Name: "Suspended", + Filter: codersdk.UsersRequest{ + Status: codersdk.UserStatusSuspended, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.Status == codersdk.UserStatusSuspended + }, + }, + { + Name: "NameContains", + Filter: codersdk.UsersRequest{ + Search: "a", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return (strings.ContainsAny(u.Username, "aA") || strings.ContainsAny(u.Email, "aA")) + }, + }, + { + Name: "NameAndSearch", + Filter: codersdk.UsersRequest{ + SearchQuery: "name:Test search:before1", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.Username == "before1" + }, + }, + { + Name: "NameNoMatch", + Filter: codersdk.UsersRequest{ + Search: "nonexistent", + }, + FilterF: func(_ codersdk.UsersRequest, _ codersdk.User) bool { + return false + }, + }, + { + Name: "Admins", + Filter: codersdk.UsersRequest{ + Role: codersdk.RoleOwner, + Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + for _, r := range u.Roles { + if r.Name == codersdk.RoleOwner { + return true + } + } + return false + }, + }, + { + Name: "AdminsUppercase", + Filter: codersdk.UsersRequest{ + Role: "OWNER", + Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + for _, r := range u.Roles { + if r.Name == codersdk.RoleOwner { + return true + } + } + return false + }, + }, + { + Name: "Members", + Filter: codersdk.UsersRequest{ + Role: codersdk.RoleMember, + Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, + }, + FilterF: func(_ codersdk.UsersRequest, _ codersdk.User) bool { + return true + }, + }, + { + Name: "SearchQuery", + Filter: codersdk.UsersRequest{ + SearchQuery: "i role:owner status:active", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + for _, r := range u.Roles { + if r.Name == codersdk.RoleOwner { + return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && + u.Status == codersdk.UserStatusActive + } + } + return false + }, + }, + { + Name: "SearchQueryInsensitive", + Filter: codersdk.UsersRequest{ + SearchQuery: "i Role:Owner STATUS:Active", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + for _, r := range u.Roles { + if r.Name == codersdk.RoleOwner { + return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && + u.Status == codersdk.UserStatusActive + } + } + return false + }, + }, + { + Name: "LastSeenBeforeNow", + Filter: codersdk.UsersRequest{ + SearchQuery: `last_seen_before:"2023-01-16T00:00:00+12:00"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.LastSeenAt.Before(lastSeenNow) + }, + }, + { + Name: "LastSeenLastWeek", + Filter: codersdk.UsersRequest{ + SearchQuery: `last_seen_before:"2023-01-14T23:59:59Z" last_seen_after:"2023-01-08T00:00:00Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + start := time.Date(2023, 1, 8, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 1, 14, 23, 59, 59, 0, time.UTC) + return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start) + }, + }, + { + Name: "CreatedAtBefore", + Filter: codersdk.UsersRequest{ + SearchQuery: `created_before:"2023-01-31T23:59:59Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC) + return u.CreatedAt.Before(end) + }, + }, + { + Name: "CreatedAtAfter", + Filter: codersdk.UsersRequest{ + SearchQuery: `created_after:"2023-01-01T00:00:00Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + return u.CreatedAt.After(start) + }, + }, + { + Name: "CreatedAtRange", + Filter: codersdk.UsersRequest{ + SearchQuery: `created_after:"2023-01-01T00:00:00Z" created_before:"2023-01-31T23:59:59Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC) + return u.CreatedAt.After(start) && u.CreatedAt.Before(end) + }, + }, + { + Name: "LoginTypeNone", + Filter: codersdk.UsersRequest{ + LoginType: []codersdk.LoginType{codersdk.LoginTypeNone}, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.LoginType == codersdk.LoginTypeNone + }, + }, + { + Name: "LoginTypeOIDC", + Filter: codersdk.UsersRequest{ + LoginType: []codersdk.LoginType{codersdk.LoginTypeOIDC}, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.LoginType == codersdk.LoginTypeOIDC + }, + }, + { + Name: "LoginTypeMultiple", + Filter: codersdk.UsersRequest{ + LoginType: []codersdk.LoginType{codersdk.LoginTypeNone, codersdk.LoginTypeGithub}, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.LoginType == codersdk.LoginTypeNone || u.LoginType == codersdk.LoginTypeGithub + }, + }, + { + Name: "DormantUserWithLoginTypeNone", + Filter: codersdk.UsersRequest{ + Status: codersdk.UserStatusSuspended, + LoginType: []codersdk.LoginType{codersdk.LoginTypeNone}, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return u.Status == codersdk.UserStatusSuspended && u.LoginType == codersdk.LoginTypeNone + }, + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + testCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got := fetch(testCtx, c.Filter) + exp := make([]codersdk.ReducedUser, 0) + for _, made := range users { + match := c.FilterF(c.Filter, made) + if match { + exp = append(exp, made.ReducedUser) + } + } + + require.ElementsMatch(t, exp, got, "expected users returned") + }) + } +} diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index d9d2c638b5..cb4c8ee65f 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -234,6 +234,34 @@ func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.Red return slice.List(members, ReducedUserFromGroupMember) } +func UserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) database.User { + return database.User{ + ID: member.UserID, + Email: member.UserEmail, + Username: member.UserUsername, + HashedPassword: member.UserHashedPassword, + CreatedAt: member.UserCreatedAt, + UpdatedAt: member.UserUpdatedAt, + Status: member.UserStatus, + RBACRoles: member.UserRbacRoles, + LoginType: member.UserLoginType, + AvatarURL: member.UserAvatarUrl, + Deleted: member.UserDeleted, + LastSeenAt: member.UserLastSeenAt, + QuietHoursSchedule: member.UserQuietHoursSchedule, + Name: member.UserName, + GithubComUserID: member.UserGithubComUserID, + } +} + +func ReducedUserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) codersdk.ReducedUser { + return ReducedUser(UserFromGroupMemberRow(member)) +} + +func ReducedUsersFromGroupMemberRows(members []database.GetGroupMembersByGroupIDPaginatedRow) []codersdk.ReducedUser { + return slice.List(members, ReducedUserFromGroupMemberRow) +} + func ReducedUsers(users []database.User) []codersdk.ReducedUser { return slice.List(users, ReducedUser) } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 55e99ff30f..04b2e4df32 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2892,6 +2892,10 @@ func (q *querier) GetGroupMembersByGroupID(ctx context.Context, arg database.Get return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupID)(ctx, arg) } +func (q *querier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupIDPaginated)(ctx, arg) +} + func (q *querier) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) { if _, err := q.GetGroupByID(ctx, arg.GroupID); err != nil { // AuthZ check return 0, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 7a49bec63a..a14e83b640 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1188,6 +1188,15 @@ func (s *MethodTestSuite) TestGroup() { check.Args(arg).Asserts(gm, policy.ActionRead) })) + s.Run("GetGroupMembersByGroupIDPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + u := testutil.Fake(s.T(), faker, database.User{}) + gm := testutil.Fake(s.T(), faker, database.GetGroupMembersByGroupIDPaginatedRow{GroupID: g.ID, UserID: u.ID}) + arg := database.GetGroupMembersByGroupIDPaginatedParams{GroupID: g.ID, IncludeSystem: false} + dbm.EXPECT().GetGroupMembersByGroupIDPaginated(gomock.Any(), arg).Return([]database.GetGroupMembersByGroupIDPaginatedRow{gm}, nil).AnyTimes() + check.Args(arg).Asserts(gm, policy.ActionRead) + })) + s.Run("GetGroupMembersCountByGroupID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { g := testutil.Fake(s.T(), faker, database.Group{}) arg := database.GetGroupMembersCountByGroupIDParams{GroupID: g.ID, IncludeSystem: false} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 26d73300ec..8b06f47f44 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1472,6 +1472,14 @@ func (m queryMetricsStore) GetGroupMembersByGroupID(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) { + start := time.Now() + r0, r1 := m.s.GetGroupMembersByGroupIDPaginated(ctx, arg) + m.queryLatencies.WithLabelValues("GetGroupMembersByGroupIDPaginated").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetGroupMembersByGroupIDPaginated").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) { start := time.Now() r0, r1 := m.s.GetGroupMembersCountByGroupID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bbed56add3..a771855b18 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2704,6 +2704,21 @@ func (mr *MockStoreMockRecorder) GetGroupMembersByGroupID(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupID", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupID), ctx, arg) } +// GetGroupMembersByGroupIDPaginated mocks base method. +func (m *MockStore) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupMembersByGroupIDPaginated", ctx, arg) + ret0, _ := ret[0].([]database.GetGroupMembersByGroupIDPaginatedRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupMembersByGroupIDPaginated indicates an expected call of GetGroupMembersByGroupIDPaginated. +func (mr *MockStoreMockRecorder) GetGroupMembersByGroupIDPaginated(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupIDPaginated", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupIDPaginated), ctx, arg) +} + // GetGroupMembersCountByGroupID mocks base method. func (m *MockStore) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index e114c1085d..1fe5ae7e36 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -393,6 +393,10 @@ func (gm GroupMember) RBACObject() rbac.Object { return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String()) } +func (gm GetGroupMembersByGroupIDPaginatedRow) RBACObject() rbac.Object { + return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String()) +} + // PrebuiltWorkspaceResource defines the interface for types that can be identified as prebuilt workspaces // and converted to their corresponding prebuilt workspace RBAC object. type PrebuiltWorkspaceResource interface { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 49a8550b68..9255236bcf 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -297,6 +297,7 @@ type sqlcQuerier interface { GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) GetGroupMembers(ctx context.Context, includeSystem bool) ([]GroupMember, error) GetGroupMembersByGroupID(ctx context.Context, arg GetGroupMembersByGroupIDParams) ([]GroupMember, error) + GetGroupMembersByGroupIDPaginated(ctx context.Context, arg GetGroupMembersByGroupIDPaginatedParams) ([]GetGroupMembersByGroupIDPaginatedRow, error) // Returns the total count of members in a group. Shows the total // count even if the caller does not have read access to ResourceGroupMember. // They only need ResourceGroup read access. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a2c26c0254..f07d0dad46 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7404,6 +7404,212 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, arg GetGroupM return items, nil } +const getGroupMembersByGroupIDPaginated = `-- name: GetGroupMembersByGroupIDPaginated :many +SELECT + user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id, COUNT(*) OVER() AS count +FROM + group_members_expanded +WHERE + group_members_expanded.group_id = $1 + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the username field, so select all + -- rows after the cursor. + (LOWER(user_username)) > ( + SELECT + LOWER(user_username) + FROM + group_members_expanded + WHERE + group_id = $1 + AND user_id = $2 + ) + ) + ELSE true + END + -- Start filters + -- Filter by email or username + AND CASE + WHEN $3 :: text != '' THEN ( + user_email ILIKE concat('%', $3, '%') + OR user_username ILIKE concat('%', $3, '%') + ) + ELSE true + END + -- Filter by name (display name) + AND CASE + WHEN $4 :: text != '' THEN + user_name ILIKE concat('%', $4, '%') + ELSE true + END + -- Filter by status + AND CASE + -- @status needs to be a text because it can be empty, If it was + -- user_status enum, it would not. + WHEN cardinality($5 :: user_status[]) > 0 THEN + user_status = ANY($5 :: user_status[]) + ELSE true + END + -- Filter by rbac_roles + AND CASE + -- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as + -- everyone is a member. + WHEN cardinality($6 :: text[]) > 0 AND 'member' != ANY($6 :: text[]) THEN + user_rbac_roles && $6 :: text[] + ELSE true + END + -- Filter by last_seen + AND CASE + WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_last_seen_at <= $7 + ELSE true + END + AND CASE + WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_last_seen_at >= $8 + ELSE true + END + -- Filter by created_at + AND CASE + WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_created_at <= $9 + ELSE true + END + AND CASE + WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_created_at >= $10 + ELSE true + END + -- Filter by system type + AND CASE + WHEN $11::bool THEN TRUE + ELSE user_is_system = false + END + AND CASE + WHEN $12 :: bigint != 0 THEN + user_github_com_user_id = $12 + ELSE true + END + -- Filter by login_type + AND CASE + WHEN cardinality($13 :: login_type[]) > 0 THEN + user_login_type = ANY($13 :: login_type[]) + ELSE true + END + -- End of filters +ORDER BY + -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. + LOWER(user_username) ASC OFFSET $14 +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF($15 :: int, 0) +` + +type GetGroupMembersByGroupIDPaginatedParams struct { + GroupID uuid.UUID `db:"group_id" json:"group_id"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + Search string `db:"search" json:"search"` + Name string `db:"name" json:"name"` + Status []UserStatus `db:"status" json:"status"` + RbacRole []string `db:"rbac_role" json:"rbac_role"` + LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"` + LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"` + CreatedBefore time.Time `db:"created_before" json:"created_before"` + CreatedAfter time.Time `db:"created_after" json:"created_after"` + IncludeSystem bool `db:"include_system" json:"include_system"` + GithubComUserID int64 `db:"github_com_user_id" json:"github_com_user_id"` + LoginType []LoginType `db:"login_type" json:"login_type"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type GetGroupMembersByGroupIDPaginatedRow struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + UserEmail string `db:"user_email" json:"user_email"` + UserUsername string `db:"user_username" json:"user_username"` + UserHashedPassword []byte `db:"user_hashed_password" json:"user_hashed_password"` + UserCreatedAt time.Time `db:"user_created_at" json:"user_created_at"` + UserUpdatedAt time.Time `db:"user_updated_at" json:"user_updated_at"` + UserStatus UserStatus `db:"user_status" json:"user_status"` + UserRbacRoles []string `db:"user_rbac_roles" json:"user_rbac_roles"` + UserLoginType LoginType `db:"user_login_type" json:"user_login_type"` + UserAvatarUrl string `db:"user_avatar_url" json:"user_avatar_url"` + UserDeleted bool `db:"user_deleted" json:"user_deleted"` + UserLastSeenAt time.Time `db:"user_last_seen_at" json:"user_last_seen_at"` + UserQuietHoursSchedule string `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` + UserName string `db:"user_name" json:"user_name"` + UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"` + UserIsSystem bool `db:"user_is_system" json:"user_is_system"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + GroupName string `db:"group_name" json:"group_name"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg GetGroupMembersByGroupIDPaginatedParams) ([]GetGroupMembersByGroupIDPaginatedRow, error) { + rows, err := q.db.QueryContext(ctx, getGroupMembersByGroupIDPaginated, + arg.GroupID, + arg.AfterID, + arg.Search, + arg.Name, + pq.Array(arg.Status), + pq.Array(arg.RbacRole), + arg.LastSeenBefore, + arg.LastSeenAfter, + arg.CreatedBefore, + arg.CreatedAfter, + arg.IncludeSystem, + arg.GithubComUserID, + pq.Array(arg.LoginType), + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetGroupMembersByGroupIDPaginatedRow + for rows.Next() { + var i GetGroupMembersByGroupIDPaginatedRow + if err := rows.Scan( + &i.UserID, + &i.UserEmail, + &i.UserUsername, + &i.UserHashedPassword, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.UserStatus, + pq.Array(&i.UserRbacRoles), + &i.UserLoginType, + &i.UserAvatarUrl, + &i.UserDeleted, + &i.UserLastSeenAt, + &i.UserQuietHoursSchedule, + &i.UserName, + &i.UserGithubComUserID, + &i.UserIsSystem, + &i.OrganizationID, + &i.GroupName, + &i.GroupID, + &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 getGroupMembersCountByGroupID = `-- name: GetGroupMembersCountByGroupID :one SELECT COUNT(*) FROM group_members_expanded diff --git a/coderd/database/queries/groupmembers.sql b/coderd/database/queries/groupmembers.sql index 858a5937a3..b2f9d5fee8 100644 --- a/coderd/database/queries/groupmembers.sql +++ b/coderd/database/queries/groupmembers.sql @@ -17,6 +17,110 @@ WHERE group_id = @group_id user_is_system = false END; +-- name: GetGroupMembersByGroupIDPaginated :many +SELECT + *, COUNT(*) OVER() AS count +FROM + group_members_expanded +WHERE + group_members_expanded.group_id = @group_id + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the username field, so select all + -- rows after the cursor. + (LOWER(user_username)) > ( + SELECT + LOWER(user_username) + FROM + group_members_expanded + WHERE + group_id = @group_id + AND user_id = @after_id + ) + ) + ELSE true + END + -- Start filters + -- Filter by email or username + AND CASE + WHEN @search :: text != '' THEN ( + user_email ILIKE concat('%', @search, '%') + OR user_username ILIKE concat('%', @search, '%') + ) + ELSE true + END + -- Filter by name (display name) + AND CASE + WHEN @name :: text != '' THEN + user_name ILIKE concat('%', @name, '%') + ELSE true + END + -- Filter by status + AND CASE + -- @status needs to be a text because it can be empty, If it was + -- user_status enum, it would not. + WHEN cardinality(@status :: user_status[]) > 0 THEN + user_status = ANY(@status :: user_status[]) + ELSE true + END + -- Filter by rbac_roles + AND CASE + -- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as + -- everyone is a member. + WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN + user_rbac_roles && @rbac_role :: text[] + ELSE true + END + -- Filter by last_seen + AND CASE + WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_last_seen_at <= @last_seen_before + ELSE true + END + AND CASE + WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_last_seen_at >= @last_seen_after + ELSE true + END + -- Filter by created_at + AND CASE + WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_created_at <= @created_before + ELSE true + END + AND CASE + WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_created_at >= @created_after + ELSE true + END + -- Filter by system type + AND CASE + WHEN @include_system::bool THEN TRUE + ELSE user_is_system = false + END + AND CASE + WHEN @github_com_user_id :: bigint != 0 THEN + user_github_com_user_id = @github_com_user_id + ELSE true + END + -- Filter by login_type + AND CASE + WHEN cardinality(@login_type :: login_type[]) > 0 THEN + user_login_type = ANY(@login_type :: login_type[]) + ELSE true + END + -- End of filters +ORDER BY + -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. + LOWER(user_username) ASC OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF(@limit_opt :: int, 0); + -- name: GetGroupMembersCountByGroupID :one -- Returns the total count of members in a group. Shows the total -- count even if the caller does not have read access to ResourceGroupMember. diff --git a/coderd/users_test.go b/coderd/users_test.go index 80d9de2d73..912d835451 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -2,7 +2,6 @@ package coderd_test import ( "context" - "database/sql" "fmt" "net/http" "slices" @@ -22,8 +21,6 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -1725,693 +1722,48 @@ func TestGetUser(t *testing.T) { }) } -// TestUsersFilter creates a set of users to run various filters against for testing. -func TestUsersFilter(t *testing.T) { +func TestGetUsersFilter(t *testing.T) { t.Parallel() - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - first := coderdtest.CreateFirstUser(t, client) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + OIDCConfig: &coderd.OIDCConfig{ + AllowSignups: true, + }, + }) + _ = coderdtest.CreateFirstUser(t, client) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - t.Cleanup(cancel) + setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - firstUser, err := client.User(ctx, codersdk.Me) - require.NoError(t, err, "fetch me") - - // Noon on Jan 18 is the "now" for this test for last_seen timestamps. - // All these values are equal - // 2023-01-18T12:00:00Z (UTC) - // 2023-01-18T07:00:00-05:00 (America/New_York) - // 2023-01-18T13:00:00+01:00 (Europe/Madrid) - // 2023-01-16T00:00:00+12:00 (Asia/Anadyr) - lastSeenNow := time.Date(2023, 1, 18, 12, 0, 0, 0, time.UTC) - users := make([]codersdk.User, 0) - users = append(users, firstUser) - for i := 0; i < 15; i++ { - roles := []rbac.RoleIdentifier{} - if i%2 == 0 { - roles = append(roles, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()) + coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser { + res, err := client.Users(testCtx, req) + require.NoError(t, err) + reduced := make([]codersdk.ReducedUser, len(res.Users)) + for i, user := range res.Users { + reduced[i] = user.ReducedUser } - if i%3 == 0 { - roles = append(roles, rbac.RoleAuditor()) - } - userClient, userData := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, roles...) - // Set the last seen for each user to a unique day - _, err := api.Database.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLastSeenAtParams{ - ID: userData.ID, - LastSeenAt: lastSeenNow.Add(-1 * time.Hour * 24 * time.Duration(i)), - UpdatedAt: time.Now(), - }) - require.NoError(t, err, "set a last seen") - - user, err := userClient.User(ctx, codersdk.Me) - require.NoError(t, err, "fetch me") - - if i%4 == 0 { - user, err = client.UpdateUserStatus(ctx, user.ID.String(), codersdk.UserStatusSuspended) - require.NoError(t, err, "suspend user") - } - - if i%5 == 0 { - user, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ - Username: strings.ToUpper(user.Username), - }) - require.NoError(t, err, "update username to uppercase") - } - - users = append(users, user) - } - - // Add users with different creation dates for testing date filters - for i := 0; i < 3; i++ { - user1, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ - ID: uuid.New(), - Email: fmt.Sprintf("before%d@coder.com", i), - Username: fmt.Sprintf("before%d", i), - LoginType: database.LoginTypeNone, - Status: string(codersdk.UserStatusActive), - RBACRoles: []string{codersdk.RoleMember}, - CreatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)), - }) - require.NoError(t, err) - - // The expected timestamps must be parsed from strings to compare equal during `ElementsMatch` - sdkUser1 := db2sdk.User(user1, nil) - sdkUser1.CreatedAt, err = time.Parse(time.RFC3339, sdkUser1.CreatedAt.Format(time.RFC3339)) - require.NoError(t, err) - sdkUser1.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser1.UpdatedAt.Format(time.RFC3339)) - require.NoError(t, err) - sdkUser1.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser1.LastSeenAt.Format(time.RFC3339)) - require.NoError(t, err) - users = append(users, sdkUser1) - - user2, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ - ID: uuid.New(), - Email: fmt.Sprintf("during%d@coder.com", i), - Username: fmt.Sprintf("during%d", i), - LoginType: database.LoginTypeNone, - Status: string(codersdk.UserStatusActive), - RBACRoles: []string{codersdk.RoleOwner}, - CreatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)), - }) - require.NoError(t, err) - - sdkUser2 := db2sdk.User(user2, nil) - sdkUser2.CreatedAt, err = time.Parse(time.RFC3339, sdkUser2.CreatedAt.Format(time.RFC3339)) - require.NoError(t, err) - sdkUser2.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser2.UpdatedAt.Format(time.RFC3339)) - require.NoError(t, err) - sdkUser2.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser2.LastSeenAt.Format(time.RFC3339)) - require.NoError(t, err) - users = append(users, sdkUser2) - - user3, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ - ID: uuid.New(), - Email: fmt.Sprintf("after%d@coder.com", i), - Username: fmt.Sprintf("after%d", i), - LoginType: database.LoginTypeNone, - Status: string(codersdk.UserStatusActive), - RBACRoles: []string{codersdk.RoleOwner}, - CreatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)), - }) - require.NoError(t, err) - - sdkUser3 := db2sdk.User(user3, nil) - sdkUser3.CreatedAt, err = time.Parse(time.RFC3339, sdkUser3.CreatedAt.Format(time.RFC3339)) - require.NoError(t, err) - sdkUser3.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser3.UpdatedAt.Format(time.RFC3339)) - require.NoError(t, err) - sdkUser3.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser3.LastSeenAt.Format(time.RFC3339)) - require.NoError(t, err) - users = append(users, sdkUser3) - } - - // --- Setup done --- - testCases := []struct { - Name string - Filter codersdk.UsersRequest - // If FilterF is true, we include it in the expected results - FilterF func(f codersdk.UsersRequest, user codersdk.User) bool - }{ - { - Name: "All", - Filter: codersdk.UsersRequest{ - Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return true - }, - }, - { - Name: "Active", - Filter: codersdk.UsersRequest{ - Status: codersdk.UserStatusActive, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return u.Status == codersdk.UserStatusActive - }, - }, - { - Name: "ActiveUppercase", - Filter: codersdk.UsersRequest{ - Status: "ACTIVE", - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return u.Status == codersdk.UserStatusActive - }, - }, - { - Name: "Suspended", - Filter: codersdk.UsersRequest{ - Status: codersdk.UserStatusSuspended, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return u.Status == codersdk.UserStatusSuspended - }, - }, - { - Name: "NameContains", - Filter: codersdk.UsersRequest{ - Search: "a", - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return (strings.ContainsAny(u.Username, "aA") || strings.ContainsAny(u.Email, "aA")) - }, - }, - { - Name: "Admins", - Filter: codersdk.UsersRequest{ - Role: codersdk.RoleOwner, - Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - for _, r := range u.Roles { - if r.Name == codersdk.RoleOwner { - return true - } - } - return false - }, - }, - { - Name: "AdminsUppercase", - Filter: codersdk.UsersRequest{ - Role: "OWNER", - Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - for _, r := range u.Roles { - if r.Name == codersdk.RoleOwner { - return true - } - } - return false - }, - }, - { - Name: "Members", - Filter: codersdk.UsersRequest{ - Role: codersdk.RoleMember, - Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return true - }, - }, - { - Name: "SearchQuery", - Filter: codersdk.UsersRequest{ - SearchQuery: "i role:owner status:active", - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - for _, r := range u.Roles { - if r.Name == codersdk.RoleOwner { - return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && - u.Status == codersdk.UserStatusActive - } - } - return false - }, - }, - { - Name: "SearchQueryInsensitive", - Filter: codersdk.UsersRequest{ - SearchQuery: "i Role:Owner STATUS:Active", - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - for _, r := range u.Roles { - if r.Name == codersdk.RoleOwner { - return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && - u.Status == codersdk.UserStatusActive - } - } - return false - }, - }, - { - Name: "LastSeenBeforeNow", - Filter: codersdk.UsersRequest{ - SearchQuery: `last_seen_before:"2023-01-16T00:00:00+12:00"`, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return u.LastSeenAt.Before(lastSeenNow) - }, - }, - { - Name: "LastSeenLastWeek", - Filter: codersdk.UsersRequest{ - SearchQuery: `last_seen_before:"2023-01-14T23:59:59Z" last_seen_after:"2023-01-08T00:00:00Z"`, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - start := time.Date(2023, 1, 8, 0, 0, 0, 0, time.UTC) - end := time.Date(2023, 1, 14, 23, 59, 59, 0, time.UTC) - return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start) - }, - }, - { - Name: "CreatedAtBefore", - Filter: codersdk.UsersRequest{ - SearchQuery: `created_before:"2023-01-31T23:59:59Z"`, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC) - return u.CreatedAt.Before(end) - }, - }, - { - Name: "CreatedAtAfter", - Filter: codersdk.UsersRequest{ - SearchQuery: `created_after:"2023-01-01T00:00:00Z"`, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) - return u.CreatedAt.After(start) - }, - }, - { - Name: "CreatedAtRange", - Filter: codersdk.UsersRequest{ - SearchQuery: `created_after:"2023-01-01T00:00:00Z" created_before:"2023-01-31T23:59:59Z"`, - }, - FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC) - return u.CreatedAt.After(start) && u.CreatedAt.Before(end) - }, - }, - } - - for _, c := range testCases { - t.Run(c.Name, func(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - matched, err := client.Users(ctx, c.Filter) - require.NoError(t, err, "fetch workspaces") - - exp := make([]codersdk.User, 0) - for _, made := range users { - match := c.FilterF(c.Filter, made) - if match { - exp = append(exp, made) - } - } - - require.ElementsMatch(t, exp, matched.Users, "expected users returned") - }) - } -} - -func TestGetUsers(t *testing.T) { - t.Parallel() - t.Run("AllUsers", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "alice@email.com", - Username: "alice", - Password: "MySecurePassword!", - OrganizationIDs: []uuid.UUID{user.OrganizationID}, - }) - // No params is all users - res, err := client.Users(ctx, codersdk.UsersRequest{}) - require.NoError(t, err) - require.Len(t, res.Users, 2) - require.Len(t, res.Users[0].OrganizationIDs, 1) - }) - t.Run("ActiveUsers", func(t *testing.T) { - t.Parallel() - active := make([]codersdk.User, 0) - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - firstUser, err := client.User(ctx, first.UserID.String()) - require.NoError(t, err, "") - active = append(active, firstUser) - - // Alice will be suspended - alice, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "alice@email.com", - Username: "alice", - Password: "MySecurePassword!", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - }) - require.NoError(t, err) - - _, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended) - require.NoError(t, err) - - // Tom will be active - tom, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "tom@email.com", - Username: "tom", - Password: "MySecurePassword!", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - }) - require.NoError(t, err) - - tom, err = client.UpdateUserStatus(ctx, tom.Username, codersdk.UserStatusActive) - require.NoError(t, err) - active = append(active, tom) - - res, err := client.Users(ctx, codersdk.UsersRequest{ - Status: codersdk.UserStatusActive, - }) - require.NoError(t, err) - require.ElementsMatch(t, active, res.Users) - }) - t.Run("GithubComUserID", func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - client, db := coderdtest.NewWithDatabase(t, nil) - first := coderdtest.CreateFirstUser(t, client) - _ = dbgen.User(t, db, database.User{ - Email: "test2@coder.com", - Username: "test2", - }) - err := db.UpdateUserGithubComUserID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserGithubComUserIDParams{ - ID: first.UserID, - GithubComUserID: sql.NullInt64{ - Int64: 123, - Valid: true, - }, - }) - require.NoError(t, err) - res, err := client.Users(ctx, codersdk.UsersRequest{ - SearchQuery: "github_com_user_id:123", - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, res.Users[0].ID, first.UserID) - }) - - t.Run("LoginTypeNoneFilter", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "bob@email.com", - Username: "bob", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - res, err := client.Users(ctx, codersdk.UsersRequest{ - LoginType: []codersdk.LoginType{codersdk.LoginTypeNone}, - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, res.Users[0].LoginType, codersdk.LoginTypeNone) - }) - - t.Run("LoginTypeMultipleFilter", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - filtered := make([]codersdk.User, 0) - - bob, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "bob@email.com", - Username: "bob", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - filtered = append(filtered, bob) - - charlie, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "charlie@email.com", - Username: "charlie", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeGithub, - }) - require.NoError(t, err) - filtered = append(filtered, charlie) - - res, err := client.Users(ctx, codersdk.UsersRequest{ - LoginType: []codersdk.LoginType{codersdk.LoginTypeNone, codersdk.LoginTypeGithub}, - }) - require.NoError(t, err) - require.Len(t, res.Users, 2) - require.ElementsMatch(t, filtered, res.Users) - }) - - t.Run("DormantUserWithLoginTypeNone", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "bob@email.com", - Username: "bob", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - _, err = client.UpdateUserStatus(ctx, "bob", codersdk.UserStatusSuspended) - require.NoError(t, err) - - res, err := client.Users(ctx, codersdk.UsersRequest{ - Status: codersdk.UserStatusSuspended, - LoginType: []codersdk.LoginType{codersdk.LoginTypeNone, codersdk.LoginTypeGithub}, - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, res.Users[0].Username, "bob") - require.Equal(t, res.Users[0].Status, codersdk.UserStatusSuspended) - require.Equal(t, res.Users[0].LoginType, codersdk.LoginTypeNone) - }) - - t.Run("LoginTypeOidcFromMultipleUser", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: &coderd.OIDCConfig{ - AllowSignups: true, - }, - }) - first := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "bob@email.com", - Username: "bob", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeOIDC, - }) - require.NoError(t, err) - - for i := range 5 { - _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: fmt.Sprintf("%d@coder.com", i), - Username: fmt.Sprintf("user%d", i), - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - } - - res, err := client.Users(ctx, codersdk.UsersRequest{ - LoginType: []codersdk.LoginType{codersdk.LoginTypeOIDC}, - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, res.Users[0].Username, "bob") - require.Equal(t, res.Users[0].LoginType, codersdk.LoginTypeOIDC) - }) - - t.Run("NameFilter", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - // Create users with different display names - _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "alice@email.com", - Username: "alice", - Name: "Alice Smith", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "bob@email.com", - Username: "bob", - Name: "Bob Johnson", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "charlie@email.com", - Username: "charlie", - Name: "Charlie Smith", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - // Filter by name "Smith" should return Alice and Charlie - res, err := client.Users(ctx, codersdk.UsersRequest{ - Name: "Smith", - }) - require.NoError(t, err) - require.Len(t, res.Users, 2) - usernames := []string{res.Users[0].Username, res.Users[1].Username} - require.ElementsMatch(t, []string{"alice", "charlie"}, usernames) - - // Filter by name "Alice" should return only Alice - res, err = client.Users(ctx, codersdk.UsersRequest{ - Name: "Alice", - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, "alice", res.Users[0].Username) - - // Filter by name "Johnson" should return only Bob - res, err = client.Users(ctx, codersdk.UsersRequest{ - Name: "Johnson", - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, "bob", res.Users[0].Username) - - // Filter by name that doesn't exist should return no users - res, err = client.Users(ctx, codersdk.UsersRequest{ - Name: "Nonexistent", - }) - require.NoError(t, err) - require.Len(t, res.Users, 0) - }) - - t.Run("NameFilterWithSearchFilter", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - // Create users with different display names and usernames - _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "alice@email.com", - Username: "alice", - Name: "Alice Developer", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "bob@email.com", - Username: "bobdev", - Name: "Bob Developer", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, - UserLoginType: codersdk.LoginTypeNone, - }) - require.NoError(t, err) - - // Filter by name "Developer" and search "alice" should return only Alice - // because name matches both but search matches only alice's username - res, err := client.Users(ctx, codersdk.UsersRequest{ - SearchQuery: "name:Developer search:alice", - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, "alice", res.Users[0].Username) + return reduced }) } func TestGetUsersPagination(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - first := coderdtest.CreateFirstUser(t, client) + _ = coderdtest.CreateFirstUser(t, client) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.User(ctx, first.UserID.String()) - require.NoError(t, err, "") - - _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ - Email: "alice@email.com", - Username: "alice", - Password: "MySecurePassword!", - OrganizationIDs: []uuid.UUID{first.OrganizationID}, + coderdtest.UsersPagination(ctx, t, client, nil, func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int) { + res, err := client.Users(ctx, req) + require.NoError(t, err) + reduced := make([]codersdk.ReducedUser, len(res.Users)) + for i, user := range res.Users { + reduced[i] = user.ReducedUser + } + return reduced, res.Count }) - require.NoError(t, err) - - res, err := client.Users(ctx, codersdk.UsersRequest{}) - require.NoError(t, err) - require.Len(t, res.Users, 2) - require.Equal(t, res.Count, 2) - - res, err = client.Users(ctx, codersdk.UsersRequest{ - Pagination: codersdk.Pagination{ - Limit: 1, - }, - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, res.Count, 2) - - res, err = client.Users(ctx, codersdk.UsersRequest{ - Pagination: codersdk.Pagination{ - Offset: 1, - }, - }) - require.NoError(t, err) - require.Len(t, res.Users, 1) - require.Equal(t, res.Count, 2) - - // if offset is higher than the count postgres returns an empty array - // and not an ErrNoRows error. - res, err = client.Users(ctx, codersdk.UsersRequest{ - Pagination: codersdk.Pagination{ - Offset: 3, - }, - }) - require.NoError(t, err) - require.Len(t, res.Users, 0) - require.Equal(t, res.Count, 0) } func TestPostTokens(t *testing.T) { diff --git a/codersdk/groups.go b/codersdk/groups.go index d458a67839..a191b280e4 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -43,6 +43,11 @@ type Group struct { OrganizationDisplayName string `json:"organization_display_name"` } +type GroupMembersResponse struct { + Users []ReducedUser `json:"users"` + Count int `json:"count"` +} + func (g Group) IsEveryone() bool { return g.ID == g.OrganizationID } @@ -130,10 +135,25 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st return resp, json.NewDecoder(res.Body).Decode(&resp) } -func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { +type GroupRequest struct { + ExcludeMembers bool `json:"exclude_members"` +} + +func (p GroupRequest) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + if p.ExcludeMembers { + q.Set("exclude_members", "true") + } + r.URL.RawQuery = q.Encode() + } +} + +func (c *Client) Group(ctx context.Context, group uuid.UUID, req GroupRequest) (Group, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/groups/%s", group.String()), nil, + req.asRequestOption(), ) if err != nil { return Group{}, xerrors.Errorf("make request: %w", err) @@ -147,6 +167,25 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { return resp, json.NewDecoder(res.Body).Decode(&resp) } +func (c *Client) GroupMembers(ctx context.Context, group uuid.UUID, req UsersRequest) (GroupMembersResponse, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/groups/%s/members", group.String()), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return GroupMembersResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return GroupMembersResponse{}, ReadBodyAsError(res) + } + var resp GroupMembersResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + type PatchGroupRequest struct { AddUsers []string `json:"add_users"` RemoveUsers []string `json:"remove_users"` diff --git a/codersdk/users.go b/codersdk/users.go index 75faf95d1b..16575351cc 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -37,6 +37,33 @@ type UsersRequest struct { Pagination } +func (req UsersRequest) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + var params []string + if req.Search != "" { + params = append(params, req.Search) + } + if req.Name != "" { + params = append(params, "name:"+req.Name) + } + if req.Status != "" { + params = append(params, "status:"+string(req.Status)) + } + if req.Role != "" { + params = append(params, "role:"+req.Role) + } + if req.SearchQuery != "" { + params = append(params, req.SearchQuery) + } + for _, lt := range req.LoginType { + params = append(params, "login_type:"+string(lt)) + } + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + } +} + // MinimalUser is the minimal information needed to identify a user and show // them on the UI. type MinimalUser struct { @@ -881,30 +908,7 @@ func (c *Client) UpdateUserQuietHoursSchedule(ctx context.Context, userIdent str func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users", nil, req.Pagination.asRequestOption(), - func(r *http.Request) { - q := r.URL.Query() - var params []string - if req.Search != "" { - params = append(params, req.Search) - } - if req.Name != "" { - params = append(params, "name:"+req.Name) - } - if req.Status != "" { - params = append(params, "status:"+string(req.Status)) - } - if req.Role != "" { - params = append(params, "role:"+req.Role) - } - if req.SearchQuery != "" { - params = append(params, req.SearchQuery) - } - for _, lt := range req.LoginType { - params = append(params, "login_type:"+string(lt)) - } - q.Set("q", strings.Join(params, " ")) - r.URL.RawQuery = q.Encode() - }, + req.asRequestOption(), ) if err != nil { return GetUsersResponse{}, err diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index a1d7a21dc1..5b7cc7f8b3 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -486,9 +486,10 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ ### Parameters -| Name | In | Type | Required | Description | -|---------|------|--------|----------|-------------| -| `group` | path | string | true | Group id | +| Name | In | Type | Required | Description | +|-------------------|-------|---------|----------|-----------------------------------| +| `group` | path | string | true | Group id | +| `exclude_members` | query | boolean | false | Exclude members from the response | ### Example responses @@ -676,6 +677,63 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get group members by group ID + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/groups/{group}/members \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /groups/{group}/members` + +### Parameters + +| Name | In | Type | Required | Description | +|------------|-------|--------------|----------|---------------------| +| `group` | path | string | true | Group id | +| `q` | query | string | false | Member search query | +| `after_id` | query | string(uuid) | false | After ID | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | + +### Example responses + +> 200 Response + +```json +{ + "count": 0, + "users": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GroupMembersResponse](schemas.md#codersdkgroupmembersresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get licenses ### Code samples @@ -1953,6 +2011,64 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get group members by organization and group name + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/{groupName}/members \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/groups/{groupName}/members` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|---------------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `groupName` | path | string | true | Group name | +| `q` | query | string | false | Member search query | +| `after_id` | query | string(uuid) | false | After ID | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | + +### Example responses + +> 200 Response + +```json +{ + "count": 0, + "users": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GroupMembersResponse](schemas.md#codersdkgroupmembersresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace quota by user ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a11f468a41..b4b050805b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4529,6 +4529,37 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | | `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | +## codersdk.GroupMembersResponse + +```json +{ + "count": 0, + "users": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-------------------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `users` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | + ## codersdk.GroupSource ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 3c29f6454f..1ff6da8b42 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -461,6 +461,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) r.Get("/", api.groupByOrganization) + r.Get("/members", api.groupMembersByOrganization) }) }) r.Route("/provisionerkeys", func(r chi.Router) { @@ -545,6 +546,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.group) r.Patch("/", api.patchGroup) r.Delete("/", api.deleteGroup) + r.Get("/members", api.groupMembers) }) }) r.Route("/workspace-quota", func(r chi.Router) { diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index ea3f6824b7..c14cf821b2 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -5,15 +5,18 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/google/uuid" "golang.org/x/xerrors" + agpl "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -393,6 +396,7 @@ func (api *API) groupByOrganization(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Enterprise // @Param group path string true "Group id" +// @Param exclude_members query bool false "Exclude members from the response" // @Success 200 {object} codersdk.Group // @Router /groups/{group} [get] func (api *API) group(rw http.ResponseWriter, r *http.Request) { @@ -401,18 +405,23 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) + excludeMembers, _ := strconv.ParseBool(r.URL.Query().Get("exclude_members")) + org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID) if err != nil { httpapi.InternalServerError(rw, err) } - users, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ - GroupID: group.ID, - IncludeSystem: false, - }) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - httpapi.InternalServerError(rw, err) - return + users := []database.GroupMember{} + if !excludeMembers { + users, err = api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ + GroupID: group.ID, + IncludeSystem: false, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } } memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{ @@ -431,6 +440,94 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { }, users, int(memberCount))) } +// @Summary Get group members by organization and group name +// @ID get-group-members-by-organization-and-group-name +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Param groupName path string true "Group name" +// @Param q query string false "Member search query" +// @Param after_id query string false "After ID" format(uuid) +// @Param limit query int false "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.GroupMembersResponse +// @Router /organizations/{organization}/groups/{groupName}/members [get] +func (api *API) groupMembersByOrganization(rw http.ResponseWriter, r *http.Request) { + api.groupMembers(rw, r) +} + +// @Summary Get group members by group ID +// @ID get-group-members-by-group-id +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param group path string true "Group id" +// @Param q query string false "Member search query" +// @Param after_id query string false "After ID" format(uuid) +// @Param limit query int false "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.GroupMembersResponse +// @Router /groups/{group}/members [get] +func (api *API) groupMembers(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + filterQuery := r.URL.Query().Get("q") + userFilterParams, filterErrs := searchquery.Users(filterQuery) + if len(filterErrs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid member search query.", + Validations: filterErrs, + }) + return + } + + paginationParams, ok := agpl.ParsePagination(rw, r) + if !ok { + return + } + + members, err := api.Database.GetGroupMembersByGroupIDPaginated(ctx, database.GetGroupMembersByGroupIDPaginatedParams{ + AfterID: paginationParams.AfterID, + GroupID: group.ID, + IncludeSystem: false, + Search: userFilterParams.Search, + Name: userFilterParams.Name, + Status: userFilterParams.Status, + RbacRole: userFilterParams.RbacRole, + LastSeenBefore: userFilterParams.LastSeenBefore, + LastSeenAfter: userFilterParams.LastSeenAfter, + CreatedAfter: userFilterParams.CreatedAfter, + CreatedBefore: userFilterParams.CreatedBefore, + GithubComUserID: userFilterParams.GithubComUserID, + LoginType: userFilterParams.LoginType, + // #nosec G115 - Pagination offsets are small and fit in int32 + OffsetOpt: int32(paginationParams.Offset), + // #nosec G115 - Pagination limits are small and fit in int32 + LimitOpt: int32(paginationParams.Limit), + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + if len(members) == 0 { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupMembersResponse{ + Users: nil, + Count: 0, + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupMembersResponse{ + Users: db2sdk.ReducedUsersFromGroupMemberRows(members), + Count: int(members[0].Count), + }) +} + // @Summary Get groups by organization // @ID get-groups-by-organization // @Security CoderSessionToken diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 43e71d646e..ac20c372f5 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "context" "net/http" "sort" "testing" @@ -9,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" @@ -584,7 +586,7 @@ func TestPatchGroup(t *testing.T) { userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) ctx := testutil.Context(t, testutil.WaitLong) - group, err := userAdminClient.Group(ctx, user.OrganizationID) + group, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{}) require.NoError(t, err) require.Equal(t, 0, group.QuotaAllowance) @@ -636,7 +638,7 @@ func TestGroup(t *testing.T) { }) require.NoError(t, err) - ggroup, err := userAdminClient.Group(ctx, group.ID) + ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{}) require.NoError(t, err) require.Equal(t, group, ggroup) }) @@ -686,7 +688,7 @@ func TestGroup(t *testing.T) { require.Contains(t, group.Members, user2.ReducedUser) require.Contains(t, group.Members, user3.ReducedUser) - ggroup, err := userAdminClient.Group(ctx, group.ID) + ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{}) require.NoError(t, err) normalizeGroupMembers(&group) normalizeGroupMembers(&ggroup) @@ -694,6 +696,38 @@ func TestGroup(t *testing.T) { require.Equal(t, group, ggroup) }) + t.Run("WithoutMembers", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + _, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2.ReducedUser) + require.Contains(t, group.Members, user3.ReducedUser) + + ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{ + ExcludeMembers: true, + }) + require.NoError(t, err) + require.Len(t, ggroup.Members, 0) + }) + t.Run("RegularUserReadGroup", func(t *testing.T) { t.Parallel() @@ -714,7 +748,7 @@ func TestGroup(t *testing.T) { }) require.NoError(t, err) - ggroup, err := client1.Group(ctx, group.ID) + ggroup, err := client1.Group(ctx, group.ID, codersdk.GroupRequest{}) require.NoError(t, err, "regular users can read groups unless workspace sharing is disabled") normalizeGroupMembers(&group) normalizeGroupMembers(&ggroup) @@ -760,7 +794,7 @@ func TestGroup(t *testing.T) { }) require.NoError(t, err) - _, err = client1.Group(ctx, group.ID) + _, err = client1.Group(ctx, group.ID, codersdk.GroupRequest{}) require.Error(t, err, "regular users cannot read groups when workspace sharing is disabled") cerr, ok := codersdk.AsError(err) require.True(t, ok) @@ -797,7 +831,7 @@ func TestGroup(t *testing.T) { err = userAdminClient.DeleteUser(ctx, user1.ID) require.NoError(t, err) - group, err = userAdminClient.Group(ctx, group.ID) + group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{}) require.NoError(t, err) require.NotContains(t, group.Members, user1.ReducedUser) }) @@ -832,7 +866,7 @@ func TestGroup(t *testing.T) { user1, err = userAdminClient.UpdateUserStatus(ctx, user1.ID.String(), codersdk.UserStatusSuspended) require.NoError(t, err) - group, err = userAdminClient.Group(ctx, group.ID) + group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{}) require.NoError(t, err) require.Len(t, group.Members, 2) require.Contains(t, group.Members, user1.ReducedUser) @@ -854,7 +888,7 @@ func TestGroup(t *testing.T) { AddUsers: []string{anotherUser.ID.String()}, }) - group, err = userAdminClient.Group(ctx, group.ID) + group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{}) require.NoError(t, err) require.Len(t, group.Members, 3) require.Contains(t, group.Members, user1.ReducedUser) @@ -916,7 +950,7 @@ func TestGroup(t *testing.T) { prebuildsUser, err := client.User(ctx, database.PrebuildsSystemUserID.String()) require.NoError(t, err) // The 'Everyone' group always has an ID that matches the organization ID. - group, err := userAdminClient.Group(ctx, user.OrganizationID) + group, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{}) require.NoError(t, err) require.Len(t, group.Members, 4) require.Equal(t, "Everyone", group.Name) @@ -971,7 +1005,7 @@ func TestGroups(t *testing.T) { normalizeGroupMembers(&group2) // Fetch everyone group for comparison - everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID) + everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{}) require.NoError(t, err) normalizeGroupMembers(&everyoneGroup) @@ -1052,7 +1086,7 @@ func TestDeleteGroup(t *testing.T) { err = userAdminClient.DeleteGroup(ctx, group1.ID) require.NoError(t, err) - _, err = userAdminClient.Group(ctx, group1.ID) + _, err = userAdminClient.Group(ctx, group1.ID, codersdk.GroupRequest{}) require.Error(t, err) cerr, ok := codersdk.AsError(err) require.True(t, ok) @@ -1114,3 +1148,87 @@ func TestDeleteGroup(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) } + +func TestGetGroupMembersFilter(t *testing.T) { + t.Parallel() + + client, db, first := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + OIDCConfig: &coderd.OIDCConfig{ + AllowSignups: true, + }, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleUserAdmin()) + + setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + group, err := userAdminClient.CreateGroup(setupCtx, first.OrganizationID, codersdk.CreateGroupRequest{ + Name: "filtered", + }) + require.NoError(t, err) + + setup := func(users []codersdk.User) { + userIDs := make([]string, len(users)) + for i, user := range users { + userIDs[i] = user.ID.String() + } + group, err = userAdminClient.PatchGroup(setupCtx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: userIDs, + }) + require.NoError(t, err) + } + fetch := func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser { + res, err := userAdminClient.GroupMembers(testCtx, group.ID, req) + require.NoError(t, err) + return res.Users + } + coderdtest.UsersFilter(setupCtx, t, client, db, setup, fetch) +} + +func TestGetGroupMembersPagination(t *testing.T) { + t.Parallel() + + client, first := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleUserAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + group, err := userAdminClient.CreateGroup(ctx, first.OrganizationID, codersdk.CreateGroupRequest{ + Name: "paginated", + }) + require.NoError(t, err) + + setup := func(users []codersdk.User) { + userIDs := make([]string, len(users)) + for i, user := range users { + userIDs[i] = user.ID.String() + } + group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: userIDs, + }) + require.NoError(t, err) + } + fetch := func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int) { + group, err := userAdminClient.GroupMembers(ctx, group.ID, req) + require.NoError(t, err) + return group.Users, group.Count + } + coderdtest.UsersPagination(ctx, t, client, setup, fetch) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c61c988ea8..147ede6945 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3351,6 +3351,17 @@ export interface GroupArguments { readonly GroupIDs: readonly string[]; } +// From codersdk/groups.go +export interface GroupMembersResponse { + readonly users: readonly ReducedUser[]; + readonly count: number; +} + +// From codersdk/groups.go +export interface GroupRequest { + readonly exclude_members: boolean; +} + // From codersdk/groups.go export type GroupSource = "oidc" | "user";