diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d21da315ffa85..deafdc42e0216 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -6824,6 +6824,18 @@ func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams users = usersFilteredByRole } + if len(params.LoginType) > 0 { + usersFilteredByLoginType := make([]database.User, 0, len(users)) + for i, user := range users { + if slice.ContainsCompare(params.LoginType, user.LoginType, func(a, b database.LoginType) bool { + return strings.EqualFold(string(a), string(b)) + }) { + usersFilteredByLoginType = append(usersFilteredByLoginType, users[i]) + } + } + users = usersFilteredByLoginType + } + if !params.CreatedBefore.IsZero() { usersFilteredByCreatedAt := make([]database.User, 0, len(users)) for i, user := range users { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 3c437cde293d3..1bf37ce0c09e6 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -395,6 +395,7 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, arg.CreatedAfter, arg.IncludeSystem, arg.GithubComUserID, + pq.Array(arg.LoginType), arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 55a3bd27e5e3f..b93ad49f8f9d4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12410,16 +12410,22 @@ WHERE github_com_user_id = $10 ELSE true END + -- Filter by login_type + AND CASE + WHEN cardinality($11 :: login_type[]) > 0 THEN + login_type = ANY($11 :: login_type[]) + ELSE true + END -- End of filters -- Authorize Filter clause will be injected below in GetAuthorizedUsers -- @authorize_filter ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(username) ASC OFFSET $11 + LOWER(username) ASC OFFSET $12 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($12 :: int, 0) + NULLIF($13 :: int, 0) ` type GetUsersParams struct { @@ -12433,6 +12439,7 @@ type GetUsersParams struct { 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"` } @@ -12472,6 +12479,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse arg.CreatedAfter, arg.IncludeSystem, arg.GithubComUserID, + pq.Array(arg.LoginType), arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 0bac76c8df14a..8757b377728a3 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -260,6 +260,12 @@ WHERE 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 + login_type = ANY(@login_type :: login_type[]) + ELSE true + END -- End of filters -- Authorize Filter clause will be injected below in GetAuthorizedUsers diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 938f725330cd0..6f4a1c337c535 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -88,6 +88,7 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { CreatedAfter: parser.Time3339Nano(values, time.Time{}, "created_after"), CreatedBefore: parser.Time3339Nano(values, time.Time{}, "created_before"), GithubComUserID: parser.Int64(values, 0, "github_com_user_id"), + LoginType: httpapi.ParseCustomList(parser, values, []database.LoginType{}, "login_type", httpapi.ParseEnum[database.LoginType]), } parser.ErrorExcessParams(values) return filter, parser.Errors diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 0a8e08e3d45fe..065937f389e4a 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -386,62 +386,69 @@ func TestSearchUsers(t *testing.T) { Name: "Empty", Query: "", Expected: database.GetUsersParams{ - Status: []database.UserStatus{}, - RbacRole: []string{}, + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, }, }, { Name: "Username", Query: "user-name", Expected: database.GetUsersParams{ - Search: "user-name", - Status: []database.UserStatus{}, - RbacRole: []string{}, + Search: "user-name", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, }, }, { Name: "UsernameWithSpaces", Query: " user-name ", Expected: database.GetUsersParams{ - Search: "user-name", - Status: []database.UserStatus{}, - RbacRole: []string{}, + Search: "user-name", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, }, }, { Name: "Username+Param", Query: "usEr-name stAtus:actiVe", Expected: database.GetUsersParams{ - Search: "user-name", - Status: []database.UserStatus{database.UserStatusActive}, - RbacRole: []string{}, + Search: "user-name", + Status: []database.UserStatus{database.UserStatusActive}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, }, }, { Name: "OnlyParams", Query: "status:acTIve sEArch:User-Name role:Owner", Expected: database.GetUsersParams{ - Search: "user-name", - Status: []database.UserStatus{database.UserStatusActive}, - RbacRole: []string{codersdk.RoleOwner}, + Search: "user-name", + Status: []database.UserStatus{database.UserStatusActive}, + RbacRole: []string{codersdk.RoleOwner}, + LoginType: []database.LoginType{}, }, }, { Name: "QuotedParam", Query: `status:SuSpenDeD sEArch:"User Name" role:meMber`, Expected: database.GetUsersParams{ - Search: "user name", - Status: []database.UserStatus{database.UserStatusSuspended}, - RbacRole: []string{codersdk.RoleMember}, + Search: "user name", + Status: []database.UserStatus{database.UserStatusSuspended}, + RbacRole: []string{codersdk.RoleMember}, + LoginType: []database.LoginType{}, }, }, { Name: "QuotedKey", Query: `"status":acTIve "sEArch":User-Name "role":Owner`, Expected: database.GetUsersParams{ - Search: "user-name", - Status: []database.UserStatus{database.UserStatusActive}, - RbacRole: []string{codersdk.RoleOwner}, + Search: "user-name", + Status: []database.UserStatus{database.UserStatusActive}, + RbacRole: []string{codersdk.RoleOwner}, + LoginType: []database.LoginType{}, }, }, { @@ -449,9 +456,48 @@ func TestSearchUsers(t *testing.T) { Name: "QuotedSpecial", Query: `search:"user:name"`, Expected: database.GetUsersParams{ - Search: "user:name", + Search: "user:name", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, + }, + }, + { + Name: "LoginType", + Query: "login_type:github", + Expected: database.GetUsersParams{ + Search: "", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{database.LoginTypeGithub}, + }, + }, + { + Name: "MultipleLoginTypesWithSpaces", + Query: "login_type:github login_type:password", + Expected: database.GetUsersParams{ + Search: "", Status: []database.UserStatus{}, RbacRole: []string{}, + LoginType: []database.LoginType{ + database.LoginTypeGithub, + database.LoginTypePassword, + }, + }, + }, + { + Name: "MultipleLoginTypesWithCommas", + Query: "login_type:github,password,none,oidc", + Expected: database.GetUsersParams{ + Search: "", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{ + database.LoginTypeGithub, + database.LoginTypePassword, + database.LoginTypeNone, + database.LoginTypeOIDC, + }, }, }, diff --git a/coderd/users.go b/coderd/users.go index 03f900c01ddeb..9b6407156cfa1 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -307,6 +307,7 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us CreatedAfter: params.CreatedAfter, CreatedBefore: params.CreatedBefore, GithubComUserID: params.GithubComUserID, + LoginType: params.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 diff --git a/coderd/users_test.go b/coderd/users_test.go index fdaad21a826a9..e32b6d0c5b927 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1902,6 +1902,126 @@ func TestGetUsers(t *testing.T) { 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) + }) } func TestGetUsersPagination(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index bdc9b521367f0..e32978c5a839e 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -28,7 +28,8 @@ type UsersRequest struct { // Filter users by status. Status UserStatus `json:"status,omitempty" typescript:"-"` // Filter users that have the given role. - Role string `json:"role,omitempty" typescript:"-"` + Role string `json:"role,omitempty" typescript:"-"` + LoginType []LoginType `json:"login_type,omitempty" typescript:"-"` SearchQuery string `json:"q,omitempty"` Pagination @@ -750,6 +751,9 @@ func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse, 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() }, diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index ed7fbdebd4c5f..af26f4bb62a2b 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -190,6 +190,8 @@ to use the Coder's filter query: `status:active last_seen_before:"2023-07-01T00:00:00Z"` - To find users who were created between January 1 and January 18, 2023: `created_before:"2023-01-18T00:00:00Z" created_after:"2023-01-01T23:59:59Z"` +- To find users who login using Github: + `login_type:github` The following filters are supported: @@ -203,3 +205,4 @@ The following filters are supported: the RFC3339Nano format. - `created_before` and `created_after` - The time a user was created. Uses the RFC3339Nano format. +- `login_type` - Represents the login type of the user. Refer to the [LoginType documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#LoginType) for a list of supported values