feat: add username and email user search filters (#27922)

## Summary

User search can now resolve exact `email:` and `username:` terms through
`GET /api/v2/users` instead of only supporting fuzzy free-text matches.
The database query already had exact email and username filters; this
wires the public search parser and API handler to those filters so
clients can ask for a single user by email without fetching every user
or depending on substring matching.

This is the API half of coder/terraform-provider-coderd#403: that
provider PR adds `data.coderd_user.email`, and this PR gives it an
efficient exact lookup path.

## Testing

- `go test ./coderd/searchquery -run '^TestSearchUsers$' -count=1`
- `go test ./coderd -run '^TestGetUsersFilter$' -count=1`
- Live API test:
  - Built local enterprise Coder from this branch.
- Started Coder on `http://127.0.0.1:39991` against a clean Postgres
database.
  - Created `lookup-target@example.com`.
- Verified `GET /api/v2/users?q=email:LOOKUP-TARGET@EXAMPLE.COM&limit=2`
returned exactly one user:

```json
{
  "count": 1,
  "users": [
    {
      "id": "efc6f909-ce0a-4731-bd2f-6e4df417aaa7",
      "username": "lookup-target",
      "email": "lookup-target@example.com"
    }
  ]
}
```

---

![flow.ai](https://img.shields.io/badge/Built_with-flow.ai-6366f1)
![Codex](https://img.shields.io/badge/GPT--5-000000)

---------

Co-authored-by: Ethan Dickson <ethanndickson@gmail.com>
This commit is contained in:
Wyatt Fry
2026-08-16 09:18:02 -04:00
committed by GitHub
parent af90d8e2be
commit a005e5cd22
10 changed files with 160 additions and 52 deletions
+18
View File
@@ -425,6 +425,24 @@ func UsersFilter(
return u.Username == "before1"
},
},
{
Name: "ExactUsername",
Filter: codersdk.UsersRequest{
SearchQuery: "username:" + strings.ToUpper(users[0].Username),
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return strings.EqualFold(u.Username, users[0].Username)
},
},
{
Name: "ExactEmail",
Filter: codersdk.UsersRequest{
SearchQuery: "email:" + strings.ToUpper(users[0].Email),
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return strings.EqualFold(u.Email, users[0].Email)
},
},
{
Name: "NameNoMatch",
Filter: codersdk.UsersRequest{
+84 -52
View File
@@ -14657,74 +14657,86 @@ WHERE
user_name ILIKE concat('%', $4, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN $5 :: text != '' THEN
lower(user_username) = lower($5)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN $6 :: text != '' THEN
lower(user_email) = lower($6)
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[])
WHEN cardinality($7 :: user_status[]) > 0 THEN
user_status = ANY($7 :: 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[]
WHEN cardinality($8 :: text[]) > 0 AND 'member' != ANY($8 :: text[]) THEN
user_rbac_roles && $8 :: 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
user_last_seen_at <= $9
ELSE true
END
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= $10
user_last_seen_at >= $10
ELSE true
END
-- Filter by created_at
AND CASE
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at <= $11
ELSE true
END
AND CASE
WHEN $12 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= $12
ELSE true
END
-- Filter by system type
AND CASE
WHEN $11::bool THEN TRUE
WHEN $13::bool THEN TRUE
ELSE user_is_system = false
END
-- Filter by github.com user ID
AND CASE
WHEN $12 :: bigint != 0 THEN
user_github_com_user_id = $12
WHEN $14 :: bigint != 0 THEN
user_github_com_user_id = $14
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality($13 :: login_type[]) > 0 THEN
user_login_type = ANY($13 :: login_type[])
WHEN cardinality($15 :: login_type[]) > 0 THEN
user_login_type = ANY($15 :: login_type[])
ELSE true
END
-- Filter by service account.
AND CASE
WHEN $14 :: boolean IS NOT NULL THEN
user_is_service_account = $14 :: boolean
WHEN $16 :: boolean IS NOT NULL THEN
user_is_service_account = $16 :: boolean
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 $15
LOWER(user_username) ASC OFFSET $17
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($16 :: int, 0)
NULLIF($18 :: int, 0)
`
type GetGroupMembersByGroupIDPaginatedParams struct {
@@ -14732,6 +14744,8 @@ type GetGroupMembersByGroupIDPaginatedParams struct {
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Name string `db:"name" json:"name"`
ExactUsername string `db:"exact_username" json:"exact_username"`
ExactEmail string `db:"exact_email" json:"exact_email"`
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"`
@@ -14776,6 +14790,8 @@ func (q *sqlQuerier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg
arg.AfterID,
arg.Search,
arg.Name,
arg.ExactUsername,
arg.ExactEmail,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
@@ -20176,74 +20192,86 @@ WHERE
users.name ILIKE concat('%', $4, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN $5 :: text != '' THEN
lower(users.username) = lower($5)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN $6 :: text != '' THEN
lower(users.email) = lower($6)
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
users.status = ANY($5 :: user_status[])
WHEN cardinality($7 :: user_status[]) > 0 THEN
users.status = ANY($7 :: user_status[])
ELSE true
END
-- Filter by global 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
users.rbac_roles && $6 :: text[]
WHEN cardinality($8 :: text[]) > 0 AND 'member' != ANY($8 :: text[]) THEN
users.rbac_roles && $8 :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
users.last_seen_at <= $7
ELSE true
END
AND CASE
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
users.last_seen_at >= $8
ELSE true
END
-- Filter by created_at (user creation date, not date added to org)
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
users.created_at <= $9
users.last_seen_at <= $9
ELSE true
END
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
users.created_at >= $10
users.last_seen_at >= $10
ELSE true
END
-- Filter by created_at (user creation date, not date added to org)
AND CASE
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
users.created_at <= $11
ELSE true
END
AND CASE
WHEN $12 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
users.created_at >= $12
ELSE true
END
-- Filter by system type
AND CASE
WHEN $11::bool THEN TRUE
WHEN $13::bool THEN TRUE
ELSE users.is_system = false
END
-- Filter by github.com user ID
AND CASE
WHEN $12 :: bigint != 0 THEN
users.github_com_user_id = $12
WHEN $14 :: bigint != 0 THEN
users.github_com_user_id = $14
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality($13 :: login_type[]) > 0 THEN
users.login_type = ANY($13 :: login_type[])
WHEN cardinality($15 :: login_type[]) > 0 THEN
users.login_type = ANY($15 :: login_type[])
ELSE true
END
-- Filter by service account.
AND CASE
WHEN $14 :: boolean IS NOT NULL THEN
users.is_service_account = $14 :: boolean
WHEN $16 :: boolean IS NOT NULL THEN
users.is_service_account = $16 :: boolean
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(users.username) ASC OFFSET $15
LOWER(users.username) ASC OFFSET $17
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($16 :: int, 0)
NULLIF($18 :: int, 0)
`
type PaginatedOrganizationMembersParams struct {
@@ -20251,6 +20279,8 @@ type PaginatedOrganizationMembersParams struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Search string `db:"search" json:"search"`
Name string `db:"name" json:"name"`
ExactUsername string `db:"exact_username" json:"exact_username"`
ExactEmail string `db:"exact_email" json:"exact_email"`
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"`
@@ -20287,6 +20317,8 @@ func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg Pagin
arg.OrganizationID,
arg.Search,
arg.Name,
arg.ExactUsername,
arg.ExactEmail,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
+12
View File
@@ -60,6 +60,18 @@ WHERE
user_name ILIKE concat('%', @name, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN @exact_username :: text != '' THEN
lower(user_username) = lower(@exact_username)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN @exact_email :: text != '' THEN
lower(user_email) = lower(@exact_email)
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
@@ -136,6 +136,18 @@ WHERE
users.name ILIKE concat('%', @name, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN @exact_username :: text != '' THEN
lower(users.username) = lower(@exact_username)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN @exact_email :: text != '' THEN
lower(users.email) = lower(@exact_email)
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
+2
View File
@@ -300,6 +300,8 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
IncludeSystem: false,
Search: userFilterParams.Search,
Name: userFilterParams.Name,
ExactUsername: userFilterParams.ExactUsername,
ExactEmail: userFilterParams.ExactEmail,
Status: userFilterParams.Status,
IsServiceAccount: userFilterParams.IsServiceAccount,
RbacRole: userFilterParams.RbacRole,
+2
View File
@@ -160,6 +160,8 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
filter := database.GetUsersParams{
Search: parser.String(values, "", "search"),
Name: parser.String(values, "", "name"),
ExactUsername: parser.String(values, "", "username"),
ExactEmail: parser.String(values, "", "email"),
Status: httpapi.ParseCustomList(parser, values, []database.UserStatus{}, "status", httpapi.ParseEnum[database.UserStatus]),
IsServiceAccount: parser.NullableBoolean(values, sql.NullBool{}, "service_account"),
RbacRole: parser.Strings(values, []string{}, "role"),
+20
View File
@@ -864,6 +864,26 @@ func TestSearchUsers(t *testing.T) {
LoginType: []database.LoginType{},
},
},
{
Name: "UsernameFilter",
Query: "username:Alice",
Expected: database.GetUsersParams{
ExactUsername: "alice",
Status: []database.UserStatus{},
RbacRole: []string{},
LoginType: []database.LoginType{},
},
},
{
Name: "EmailFilter",
Query: "email:Alice@Example.com",
Expected: database.GetUsersParams{
ExactEmail: "alice@example.com",
Status: []database.UserStatus{},
RbacRole: []string{},
LoginType: []database.LoginType{},
},
},
{
Name: "NameFilterWithOtherParams",
Query: "name:John status:active role:owner",
+2
View File
@@ -390,6 +390,8 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us
AfterID: paginationParams.AfterID,
Search: params.Search,
Name: params.Name,
ExactUsername: params.ExactUsername,
ExactEmail: params.ExactEmail,
Status: params.Status,
IsServiceAccount: params.IsServiceAccount,
RbacRole: params.RbacRole,
+6
View File
@@ -201,6 +201,12 @@ contains `jane`.
The following filters are supported:
- `username` - Matches the exact username of the user. The match ignores
letter case.
- `email` - Matches the exact email address of the user. The match ignores
letter case.
- `name` - Matches part of the display name of the user. The match ignores
letter case.
- `status` - Indicates the status of the user. It can be either `active`,
`dormant` or `suspended`.
- `role` - Represents the role of the user. You can refer to the
+2
View File
@@ -500,6 +500,8 @@ func (api *API) groupMembers(rw http.ResponseWriter, r *http.Request) {
IncludeSystem: false,
Search: userFilterParams.Search,
Name: userFilterParams.Name,
ExactUsername: userFilterParams.ExactUsername,
ExactEmail: userFilterParams.ExactEmail,
Status: userFilterParams.Status,
IsServiceAccount: userFilterParams.IsServiceAccount,
RbacRole: userFilterParams.RbacRole,