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
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: