User: Support sort query param for user and org user, search endpoints (#75229)

* User: Add sort option to user search

* Switch to an approach that uses the dashboard search options

* Cable user sort on the org endpoint

* Alias user table with u in org store

* Add test and cover orgs/:orgID/users/search endpoint

* Add test to userimpl store

* Simplify the store_test with sortopts.ParseSortQueryParam

* Account for PR feedback

* Positive check

* Update docs

* Update docs

* Switch to ErrOrFallback

Co-authored-by: Karl Persson <kalle.persson@grafana.com>

---------

Co-authored-by: Karl Persson <kalle.persson@grafana.com>
This commit is contained in:
Gabriel MABILLE 2023-09-28 10:16:18 +02:00 committed by GitHub
parent 4563fc48af
commit 96cbe70b14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 279 additions and 78 deletions

View File

@ -28,7 +28,7 @@ API Tokens can be used with Organization HTTP API to get users of specific organ
## Search Users
`GET /api/users?perpage=10&page=1`
`GET /api/users?perpage=10&page=1&sort=login-asc,email-asc`
**Required permissions**
@ -49,6 +49,8 @@ Authorization: Basic YWRtaW46YWRtaW4=
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. Requires basic authentication and that the authenticated user is a Grafana Admin.
The `sort` param is an optional comma separated list of options to order the search result. Accepted values for the sort filter are: `login-asc`, `login-desc`, `email-asc`, `email-desc`, `name-asc`, `name-desc`, `lastSeenAtAge-asc`, `lastSeenAtAge-desc`. By default, if `sort` is not specified, the user list will be ordered by `login`, `email` in ascending order.
**Example Response**:
```http
@ -83,7 +85,7 @@ Content-Type: application/json
## Search Users with Paging
`GET /api/users/search?perpage=10&page=1&query=mygraf`
`GET /api/users/search?perpage=10&page=1&query=mygraf&sort=login-asc,email-asc`
**Required permissions**
@ -104,6 +106,8 @@ Authorization: Basic YWRtaW46YWRtaW4=
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`, `login` or `email` fields. Query values with spaces need to be URL encoded e.g. `query=Jane%20Doe`.
The `sort` param is an optional comma separated list of options to order the search result. Accepted values for the sort filter are: `login-asc`, `login-desc`, `email-asc`, `email-desc`, `name-asc`, `name-desc`, `lastSeenAtAge-asc`, `lastSeenAtAge-desc`. By default, if `sort` is not specified, the user list will be ordered by `login`, `email` in ascending order.
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:

View File

@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/searchusers/sortopts"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@ -236,12 +237,18 @@ func (hs *HTTPServer) SearchOrgUsers(c *contextmodel.ReqContext) response.Respon
page = 1
}
sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort"))
if err != nil {
return response.Err(err)
}
result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{
OrgID: orgID,
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
OrgID: orgID,
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
SortOpts: sortOpts,
})
if err != nil {
@ -264,12 +271,18 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *contextmodel.ReqContext) respo
page = 1
}
sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort"))
if err != nil {
return response.Err(err)
}
query := &org.SearchOrgUsersQuery{
OrgID: c.SignedInUser.GetOrgID(),
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
OrgID: c.SignedInUser.GetOrgID(),
Query: c.Query("query"),
Page: page,
Limit: perPage,
User: c.SignedInUser,
SortOpts: sortOpts,
}
result, err := hs.searchOrgUsersHelper(c, query)

View File

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/util/errutil"
)
@ -178,11 +179,12 @@ type GetOrgUsersQuery struct {
}
type SearchOrgUsersQuery struct {
UserID int64 `xorm:"user_id"`
OrgID int64 `xorm:"org_id"`
Query string
Page int
Limit int
UserID int64 `xorm:"user_id"`
OrgID int64 `xorm:"org_id"`
Query string
Page int
Limit int
SortOpts []model.SortOption
// Flag used to allow oss edition to query users without access control
DontEnforceAccessControl bool

View File

@ -546,7 +546,7 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser
}
err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error {
sess := dbSession.Table("org_user")
sess.Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user")))
sess.Join("INNER", []string{ss.dialect.Quote("user"), "u"}, "org_user.user_id=u.id")
whereConditions := make([]string, 0)
whereParams := make([]any, 0)
@ -559,7 +559,7 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser
whereParams = append(whereParams, query.UserID)
}
whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = ?", ss.dialect.Quote("user")))
whereConditions = append(whereConditions, "u.is_service_account = ?")
whereParams = append(whereParams, ss.dialect.BooleanStr(false))
if query.User == nil {
@ -593,16 +593,25 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser
sess.Cols(
"org_user.org_id",
"org_user.user_id",
"user.email",
"user.name",
"user.login",
"u.email",
"u.name",
"u.login",
"org_user.role",
"user.last_seen_at",
"user.created",
"user.updated",
"user.is_disabled",
"u.last_seen_at",
"u.created",
"u.updated",
"u.is_disabled",
)
sess.Asc("user.email", "user.login")
if len(query.SortOpts) > 0 {
for i := range query.SortOpts {
for j := range query.SortOpts[i].Filter {
sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy())
}
}
} else {
sess.Asc("u.login", "u.email")
}
if err := sess.Find(&result.OrgUsers); err != nil {
return err
@ -611,7 +620,7 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser
// get total count
orgUser := org.OrgUser{}
countSess := dbSession.Table("org_user").
Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user")))
Join("INNER", []string{ss.dialect.Quote("user"), "u"}, "org_user.user_id=u.id")
if len(whereConditions) > 0 {
countSess.Where(strings.Join(whereConditions, " AND "), whereParams...)

View File

@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
"github.com/grafana/grafana/pkg/services/searchusers/sortopts"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/user"
@ -404,6 +405,42 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) {
require.Equal(t, len(result.OrgUsers), 1)
require.Equal(t, result.OrgUsers[0].Email, ac1.Email)
})
t.Run("Can get organization users with custom ordering login-asc", func(t *testing.T) {
sortOpts, err := sortopts.ParseSortQueryParam("login-asc,email-asc")
require.NoError(t, err)
query := org.SearchOrgUsersQuery{
OrgID: ac1.OrgID,
SortOpts: sortOpts,
User: &user.SignedInUser{
OrgID: ac1.OrgID,
Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
},
}
result, err := orgUserStore.SearchOrgUsers(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(result.OrgUsers), 2)
require.Equal(t, result.OrgUsers[0].Email, ac1.Email)
require.Equal(t, result.OrgUsers[1].Email, ac2.Email)
})
t.Run("Can get organization users with custom ordering login-desc", func(t *testing.T) {
sortOpts, err := sortopts.ParseSortQueryParam("login-desc,email-asc")
require.NoError(t, err)
query := org.SearchOrgUsersQuery{
OrgID: ac1.OrgID,
SortOpts: sortOpts,
User: &user.SignedInUser{
OrgID: ac1.OrgID,
Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
},
}
result, err := orgUserStore.SearchOrgUsers(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(result.OrgUsers), 2)
require.Equal(t, result.OrgUsers[0].Email, ac2.Email)
require.Equal(t, result.OrgUsers[1].Email, ac1.Email)
})
t.Run("Cannot update role so no one is admin user", func(t *testing.T) {
remCmd := org.RemoveOrgUserCommand{OrgID: ac1.OrgID, UserID: ac2.ID, ShouldDeleteOrphanedUser: true}
err := orgUserStore.RemoveOrgUser(context.Background(), &remCmd)

View File

@ -2,10 +2,44 @@ package model
import (
"strings"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
)
// FilterWhere limits the set of dashboard IDs to the dashboards for
// which the filter is applicable. Results where the first value is
// an empty string are discarded.
type FilterWhere interface {
Where() (string, []any)
}
// FilterWith returns any recursive CTE queries (if supported)
// and their parameters
type FilterWith interface {
With() (string, []any)
}
// FilterGroupBy should be used after performing an outer join on the
// search result to ensure there is only one of each ID in the results.
// The id column must be present in the result.
type FilterGroupBy interface {
GroupBy() (string, []any)
}
// FilterOrderBy provides an ordering for the search result.
type FilterOrderBy interface {
OrderBy() string
}
// FilterLeftJoin adds the returned string as a "LEFT OUTER JOIN" to
// allow for fetching extra columns from a table outside of the
// dashboard column.
type FilterLeftJoin interface {
LeftJoin() string
}
type FilterSelect interface {
Select() string
}
type SortOption struct {
Name string
DisplayName string
@ -16,7 +50,7 @@ type SortOption struct {
}
type SortOptionFilter interface {
searchstore.FilterOrderBy
FilterOrderBy
}
type HitType string

View File

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/searchusers/sortopts"
"github.com/grafana/grafana/pkg/services/user"
)
@ -42,7 +43,7 @@ func ProvideUsersService(searchUserFilter user.SearchUserFilter, userService use
func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response {
result, err := s.SearchUser(c)
if err != nil {
return response.Error(500, "Failed to fetch users", err)
return response.ErrOrFallback(500, "Failed to fetch users", err)
}
return response.JSON(http.StatusOK, result.Users)
@ -61,7 +62,7 @@ func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response {
func (s *OSSService) SearchUsersWithPaging(c *contextmodel.ReqContext) response.Response {
result, err := s.SearchUser(c)
if err != nil {
return response.Error(500, "Failed to fetch users", err)
return response.ErrOrFallback(500, "Failed to fetch users", err)
}
return response.JSON(http.StatusOK, result)
@ -87,6 +88,11 @@ func (s *OSSService) SearchUser(c *contextmodel.ReqContext) (*user.SearchUserQue
}
}
sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort"))
if err != nil {
return nil, err
}
query := &user.SearchUsersQuery{
// added SignedInUser to the query, as to only list the users that the user has permission to read
SignedInUser: c.SignedInUser,
@ -94,6 +100,7 @@ func (s *OSSService) SearchUser(c *contextmodel.ReqContext) (*user.SearchUserQue
Filters: filters,
Page: page,
Limit: perPage,
SortOpts: sortOpts,
}
res, err := s.userService.Search(c.Req.Context(), query)
if err != nil {

View File

@ -0,0 +1,91 @@
package sortopts
import (
"fmt"
"sort"
"strings"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/util/errutil"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
// SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s)
SortOptionsByQueryParam = map[string]model.SortOption{
"login-asc": newSortOption("login", false, 0),
"login-desc": newSortOption("login", true, 0),
"email-asc": newSortOption("email", false, 1),
"email-desc": newSortOption("email", true, 1),
"name-asc": newSortOption("name", false, 2),
"name-desc": newSortOption("name", true, 2),
"lastSeenAtAge-asc": newTimeSortOption("last_seen_at", false, 3),
"lastSeenAtAge-desc": newTimeSortOption("last_seen_at", true, 3),
}
ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option")
)
type Sorter struct {
Field string
Descending bool
}
func (s Sorter) OrderBy() string {
if s.Descending {
return fmt.Sprintf("u.%v DESC", s.Field)
}
return fmt.Sprintf("u.%v ASC", s.Field)
}
func newSortOption(field string, desc bool, index int) model.SortOption {
direction := "asc"
description := ("A-Z")
if desc {
direction = "desc"
description = ("Z-A")
}
return model.SortOption{
Name: fmt.Sprintf("%v-%v", field, direction),
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction),
Index: index,
Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}},
}
}
func newTimeSortOption(field string, desc bool, index int) model.SortOption {
direction := "asc"
description := ("Oldest-Newest")
if desc {
direction = "desc"
description = ("Newest-Oldest")
}
return model.SortOption{
Name: fmt.Sprintf("%v-%v", field, direction),
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction),
Index: index,
Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}},
}
}
// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s)
func ParseSortQueryParam(param string) ([]model.SortOption, error) {
opts := []model.SortOption{}
if param != "" {
optsStr := strings.Split(param, ",")
for i := range optsStr {
if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok {
return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i])
} else {
opts = append(opts, opt)
}
}
sort.Slice(opts, func(i, j int) bool {
return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name)
})
}
return opts, nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
@ -61,11 +62,11 @@ func (b *Builder) buildSelect() {
folder.title AS folder_title `)
for _, f := range b.Filters {
if f, ok := f.(FilterSelect); ok {
if f, ok := f.(model.FilterSelect); ok {
b.sql.WriteString(fmt.Sprintf(", %s", f.Select()))
}
if f, ok := f.(FilterWith); ok {
if f, ok := f.(model.FilterWith); ok {
recQuery, recQueryParams = f.With()
}
}
@ -98,14 +99,14 @@ func (b *Builder) applyFilters() (ordering string) {
orders := []string{}
for _, f := range b.Filters {
if f, ok := f.(FilterLeftJoin); ok {
if f, ok := f.(model.FilterLeftJoin); ok {
s := f.LeftJoin()
if s != "" {
joins = append(joins, fmt.Sprintf(" LEFT OUTER JOIN %s ", s))
}
}
if f, ok := f.(FilterWhere); ok {
if f, ok := f.(model.FilterWhere); ok {
sql, params := f.Where()
if sql != "" {
wheres = append(wheres, sql)
@ -113,7 +114,7 @@ func (b *Builder) applyFilters() (ordering string) {
}
}
if f, ok := f.(FilterGroupBy); ok {
if f, ok := f.(model.FilterGroupBy); ok {
sql, params := f.GroupBy()
if sql != "" {
groups = append(groups, sql)
@ -121,8 +122,8 @@ func (b *Builder) applyFilters() (ordering string) {
}
}
if f, ok := f.(FilterOrderBy); ok {
if f, ok := f.(FilterLeftJoin); ok {
if f, ok := f.(model.FilterOrderBy); ok {
if f, ok := f.(model.FilterLeftJoin); ok {
orderJoins = append(orderJoins, fmt.Sprintf(" LEFT OUTER JOIN %s ", f.LeftJoin()))
}
orders = append(orders, f.OrderBy())

View File

@ -5,45 +5,10 @@ import (
"strings"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// FilterWhere limits the set of dashboard IDs to the dashboards for
// which the filter is applicable. Results where the first value is
// an empty string are discarded.
type FilterWhere interface {
Where() (string, []any)
}
// FilterWith returns any recursive CTE queries (if supported)
// and their parameters
type FilterWith interface {
With() (string, []any)
}
// FilterGroupBy should be used after performing an outer join on the
// search result to ensure there is only one of each ID in the results.
// The id column must be present in the result.
type FilterGroupBy interface {
GroupBy() (string, []any)
}
// FilterOrderBy provides an ordering for the search result.
type FilterOrderBy interface {
OrderBy() string
}
// FilterLeftJoin adds the returned string as a "LEFT OUTER JOIN" to
// allow for fetching extra columns from a table outside of the
// dashboard column.
type FilterLeftJoin interface {
LeftJoin() string
}
type FilterSelect interface {
Select() string
}
const (
TypeFolder = "dash-folder"
TypeDashboard = "dash-db"
@ -220,7 +185,7 @@ func sqlUIDin(column string, uids []string) (string, []any) {
type FolderWithAlertsFilter struct {
}
var _ FilterWhere = &FolderWithAlertsFilter{}
var _ model.FilterWhere = &FolderWithAlertsFilter{}
func (f FolderWithAlertsFilter) Where() (string, []any) {
return "EXISTS (SELECT 1 FROM alert_rule WHERE alert_rule.namespace_uid = dashboard.uid)", nil

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/search/model"
)
type HelpFlags1 uint64
@ -112,6 +113,7 @@ type SearchUsersQuery struct {
Page int
Limit int
AuthModule string
SortOpts []model.SortOption
Filters []Filter
IsDisabled *bool

View File

@ -679,7 +679,17 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*
}
sess.Cols("u.id", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module")
sess.Asc("u.login", "u.email")
if len(query.SortOpts) > 0 {
for i := range query.SortOpts {
for j := range query.SortOpts[i].Filter {
sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy())
}
}
} else {
sess.Asc("u.login", "u.email")
}
if err := sess.Find(&result.Users); err != nil {
return err
}

View File

@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
"github.com/grafana/grafana/pkg/services/searchusers/sortopts"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/user"
@ -822,6 +823,31 @@ func TestIntegrationUserDataAccess(t *testing.T) {
require.Nil(t, err)
require.Len(t, queryResult.Users, 1)
require.EqualValues(t, queryResult.TotalCount, 1)
// Custom ordering
sortOpts, err := sortopts.ParseSortQueryParam("login-asc,email-asc")
require.NoError(t, err)
query = user.SearchUsersQuery{Query: "", Page: 1, Limit: 3, SignedInUser: usr, SortOpts: sortOpts}
queryResult, err = userStore.Search(context.Background(), &query)
require.Nil(t, err)
require.Len(t, queryResult.Users, 3)
require.EqualValues(t, queryResult.TotalCount, 5)
for i := 0; i < 3; i++ {
require.Equal(t, fmt.Sprint("loginuser", i), queryResult.Users[i].Login)
}
sortOpts2, err := sortopts.ParseSortQueryParam("login-desc,email-asc")
require.NoError(t, err)
query = user.SearchUsersQuery{Query: "", Page: 1, Limit: 3, SignedInUser: usr, SortOpts: sortOpts2}
queryResult, err = userStore.Search(context.Background(), &query)
require.Nil(t, err)
require.Len(t, queryResult.Users, 3)
require.EqualValues(t, queryResult.TotalCount, 5)
for i := 0; i < 3; i++ {
require.Equal(t, fmt.Sprint("loginuser", 4-i), queryResult.Users[i].Login)
}
})
t.Run("Can get logged in user projection", func(t *testing.T) {