Identity: Add endpoint to get display info for an identifier (#91828)

pull/91951/head
Ryan McKinley 1 year ago committed by GitHub
parent c7fdf8ce70
commit a0cd89860e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      pkg/api/admin_users.go
  2. 4
      pkg/api/http_server.go
  3. 2
      pkg/api/user.go
  4. 2
      pkg/apimachinery/apis/identity/v0alpha1/register.go
  5. 26
      pkg/apimachinery/apis/identity/v0alpha1/types.go
  6. 25
      pkg/apimachinery/apis/identity/v0alpha1/zz_generated.deepcopy.go
  7. 79
      pkg/apimachinery/apis/identity/v0alpha1/zz_generated.openapi.go
  8. 2
      pkg/apimachinery/apis/identity/v0alpha1/zz_generated.openapi_violation_exceptions.list
  9. 43
      pkg/registry/apis/dashboard/legacy/sql_dashboards.go
  10. 191
      pkg/registry/apis/identity/display.go
  11. 186
      pkg/registry/apis/identity/legacy/legacy_sql.go
  12. 58
      pkg/registry/apis/identity/legacy/queries.go
  13. 207
      pkg/registry/apis/identity/legacy/queries_test.go
  14. 13
      pkg/registry/apis/identity/legacy/query_display.sql
  15. 11
      pkg/registry/apis/identity/legacy/query_teams.sql
  16. 13
      pkg/registry/apis/identity/legacy/query_users.sql
  17. 8
      pkg/registry/apis/identity/legacy/testdata/mysql__display_ids.sql
  18. 9
      pkg/registry/apis/identity/legacy/testdata/mysql__display_ids_uids.sql
  19. 8
      pkg/registry/apis/identity/legacy/testdata/mysql__display_uids.sql
  20. 5
      pkg/registry/apis/identity/legacy/testdata/mysql__teams_page_1.sql
  21. 6
      pkg/registry/apis/identity/legacy/testdata/mysql__teams_page_2.sql
  22. 6
      pkg/registry/apis/identity/legacy/testdata/mysql__teams_uid.sql
  23. 7
      pkg/registry/apis/identity/legacy/testdata/mysql__users_page_1.sql
  24. 8
      pkg/registry/apis/identity/legacy/testdata/mysql__users_page_2.sql
  25. 8
      pkg/registry/apis/identity/legacy/testdata/mysql__users_uid.sql
  26. 8
      pkg/registry/apis/identity/legacy/testdata/postgres__display_ids.sql
  27. 9
      pkg/registry/apis/identity/legacy/testdata/postgres__display_ids_uids.sql
  28. 8
      pkg/registry/apis/identity/legacy/testdata/postgres__display_uids.sql
  29. 5
      pkg/registry/apis/identity/legacy/testdata/postgres__teams_page_1.sql
  30. 6
      pkg/registry/apis/identity/legacy/testdata/postgres__teams_page_2.sql
  31. 6
      pkg/registry/apis/identity/legacy/testdata/postgres__teams_uid.sql
  32. 7
      pkg/registry/apis/identity/legacy/testdata/postgres__users_page_1.sql
  33. 8
      pkg/registry/apis/identity/legacy/testdata/postgres__users_page_2.sql
  34. 8
      pkg/registry/apis/identity/legacy/testdata/postgres__users_uid.sql
  35. 8
      pkg/registry/apis/identity/legacy/testdata/sqlite__display_ids.sql
  36. 9
      pkg/registry/apis/identity/legacy/testdata/sqlite__display_ids_uids.sql
  37. 8
      pkg/registry/apis/identity/legacy/testdata/sqlite__display_uids.sql
  38. 5
      pkg/registry/apis/identity/legacy/testdata/sqlite__teams_page_1.sql
  39. 6
      pkg/registry/apis/identity/legacy/testdata/sqlite__teams_page_2.sql
  40. 6
      pkg/registry/apis/identity/legacy/testdata/sqlite__teams_uid.sql
  41. 7
      pkg/registry/apis/identity/legacy/testdata/sqlite__users_page_1.sql
  42. 8
      pkg/registry/apis/identity/legacy/testdata/sqlite__users_page_2.sql
  43. 8
      pkg/registry/apis/identity/legacy/testdata/sqlite__users_uid.sql
  44. 50
      pkg/registry/apis/identity/legacy/types.go
  45. 36
      pkg/registry/apis/identity/legacy_sa.go
  46. 73
      pkg/registry/apis/identity/legacy_teams.go
  47. 87
      pkg/registry/apis/identity/legacy_user_teams.go
  48. 41
      pkg/registry/apis/identity/legacy_users.go
  49. 39
      pkg/registry/apis/identity/register.go
  50. 5
      pkg/services/apiserver/auth/authorizer/org_id.go
  51. 6
      pkg/services/apiserver/builder/helper.go
  52. 7
      pkg/services/team/model.go
  53. 1
      pkg/services/team/team.go
  54. 16
      pkg/services/team/teamimpl/store.go
  55. 8
      pkg/services/team/teamimpl/team.go
  56. 4
      pkg/services/team/teamtest/team.go
  57. 7
      pkg/services/user/model.go
  58. 1
      pkg/services/user/user.go
  59. 35
      pkg/services/user/userimpl/store.go
  60. 9
      pkg/services/user/userimpl/user.go
  61. 4
      pkg/services/user/userimpl/user_test.go
  62. 4
      pkg/services/user/usertest/fake.go
  63. 30
      pkg/services/user/usertest/mock.go
  64. 8
      pkg/storage/legacysql/README.md
  65. 51
      pkg/storage/legacysql/rv.go
  66. 80
      pkg/tests/apis/helper.go
  67. 150
      pkg/tests/apis/identity/identity_test.go

@ -229,7 +229,7 @@ func (hs *HTTPServer) AdminDeleteUser(c *contextmodel.ReqContext) response.Respo
return nil
})
g.Go(func() error {
if err := hs.teamService.RemoveUsersMemberships(ctx, cmd.UserID); err != nil {
if err := hs.TeamService.RemoveUsersMemberships(ctx, cmd.UserID); err != nil {
return err
}
return nil

@ -202,7 +202,7 @@ type HTTPServer struct {
tempUserService tempUser.Service
loginAttemptService loginAttempt.Service
orgService org.Service
teamService team.Service
TeamService team.Service
accesscontrolService accesscontrol.Service
annotationsRepo annotations.Repository
tagService tag.Service
@ -352,7 +352,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
tempUserService: tempUserService,
loginAttemptService: loginAttemptService,
orgService: orgService,
teamService: teamService,
TeamService: teamService,
navTreeService: navTreeService,
accesscontrolService: accesscontrolService,
annotationsRepo: annotationRepo,

@ -401,7 +401,7 @@ func (hs *HTTPServer) GetUserTeams(c *contextmodel.ReqContext) response.Response
func (hs *HTTPServer) getUserTeamList(c *contextmodel.ReqContext, orgID int64, userID int64) response.Response {
query := team.GetTeamsByUserQuery{OrgID: orgID, UserID: userID, SignedInUser: c.SignedInUser}
queryResult, err := hs.teamService.GetTeamsByUser(c.Req.Context(), &query)
queryResult, err := hs.TeamService.GetTeamsByUser(c.Req.Context(), &query)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to get user teams", err)
}

@ -120,7 +120,7 @@ func AddKnownTypes(scheme *runtime.Scheme, version string) error {
&ServiceAccountList{},
&Team{},
&TeamList{},
&IdentityDisplayList{},
&IdentityDisplayResults{},
)
// metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil

@ -1,6 +1,7 @@
package v0alpha1
import (
"github.com/grafana/authlib/claims"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@ -73,19 +74,28 @@ type ServiceAccountList struct {
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type IdentityDisplayList struct {
type IdentityDisplayResults struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []IdentityDisplay `json:"items,omitempty"`
// Request keys used to lookup the display value
// +listType=set
Keys []string `json:"keys"`
// Matching items (the caller may need to remap from keys to results)
// +listType=atomic
Display []IdentityDisplay `json:"display"`
// Input keys that were not useable
// +listType=set
InvalidKeys []string `json:"invalidKeys,omitempty"`
}
type IdentityDisplay struct {
IdentityType string `json:"type"` // The namespaced UID, eg `user|api-key|...`
UID string `json:"uid"` // The namespaced UID, eg `xyz`
Display string `json:"display"`
AvatarURL string `json:"avatarURL,omitempty"`
IdentityType claims.IdentityType `json:"type"` // The namespaced UID, eg `user|api-key|...`
UID string `json:"uid"` // The namespaced UID, eg `xyz`
Display string `json:"display"`
AvatarURL string `json:"avatarURL,omitempty"`
// Legacy internal ID -- usage of this value should be phased out
LegacyID int64 `json:"legacyId,omitempty"`
InternalID int64 `json:"internalId,omitempty"`
}

@ -28,30 +28,39 @@ func (in *IdentityDisplay) DeepCopy() *IdentityDisplay {
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IdentityDisplayList) DeepCopyInto(out *IdentityDisplayList) {
func (in *IdentityDisplayResults) DeepCopyInto(out *IdentityDisplayResults) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
if in.Keys != nil {
in, out := &in.Keys, &out.Keys
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Display != nil {
in, out := &in.Display, &out.Display
*out = make([]IdentityDisplay, len(*in))
copy(*out, *in)
}
if in.InvalidKeys != nil {
in, out := &in.InvalidKeys, &out.InvalidKeys
*out = make([]string, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IdentityDisplayList.
func (in *IdentityDisplayList) DeepCopy() *IdentityDisplayList {
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IdentityDisplayResults.
func (in *IdentityDisplayResults) DeepCopy() *IdentityDisplayResults {
if in == nil {
return nil
}
out := new(IdentityDisplayList)
out := new(IdentityDisplayResults)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *IdentityDisplayList) DeepCopyObject() runtime.Object {
func (in *IdentityDisplayResults) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}

@ -14,17 +14,17 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.IdentityDisplay": schema_apimachinery_apis_identity_v0alpha1_IdentityDisplay(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.IdentityDisplayList": schema_apimachinery_apis_identity_v0alpha1_IdentityDisplayList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.ServiceAccount": schema_apimachinery_apis_identity_v0alpha1_ServiceAccount(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.ServiceAccountList": schema_apimachinery_apis_identity_v0alpha1_ServiceAccountList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.ServiceAccountSpec": schema_apimachinery_apis_identity_v0alpha1_ServiceAccountSpec(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.Team": schema_apimachinery_apis_identity_v0alpha1_Team(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.TeamList": schema_apimachinery_apis_identity_v0alpha1_TeamList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.TeamSpec": schema_apimachinery_apis_identity_v0alpha1_TeamSpec(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.User": schema_apimachinery_apis_identity_v0alpha1_User(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.UserList": schema_apimachinery_apis_identity_v0alpha1_UserList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.UserSpec": schema_apimachinery_apis_identity_v0alpha1_UserSpec(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.IdentityDisplay": schema_apimachinery_apis_identity_v0alpha1_IdentityDisplay(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.IdentityDisplayResults": schema_apimachinery_apis_identity_v0alpha1_IdentityDisplayResults(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.ServiceAccount": schema_apimachinery_apis_identity_v0alpha1_ServiceAccount(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.ServiceAccountList": schema_apimachinery_apis_identity_v0alpha1_ServiceAccountList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.ServiceAccountSpec": schema_apimachinery_apis_identity_v0alpha1_ServiceAccountSpec(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.Team": schema_apimachinery_apis_identity_v0alpha1_Team(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.TeamList": schema_apimachinery_apis_identity_v0alpha1_TeamList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.TeamSpec": schema_apimachinery_apis_identity_v0alpha1_TeamSpec(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.User": schema_apimachinery_apis_identity_v0alpha1_User(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.UserList": schema_apimachinery_apis_identity_v0alpha1_UserList(ref),
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.UserSpec": schema_apimachinery_apis_identity_v0alpha1_UserSpec(ref),
}
}
@ -63,7 +63,7 @@ func schema_apimachinery_apis_identity_v0alpha1_IdentityDisplay(ref common.Refer
Format: "",
},
},
"legacyId": {
"internalId": {
SchemaProps: spec.SchemaProps{
Description: "Legacy internal ID -- usage of this value should be phased out",
Type: []string{"integer"},
@ -77,7 +77,7 @@ func schema_apimachinery_apis_identity_v0alpha1_IdentityDisplay(ref common.Refer
}
}
func schema_apimachinery_apis_identity_v0alpha1_IdentityDisplayList(ref common.ReferenceCallback) common.OpenAPIDefinition {
func schema_apimachinery_apis_identity_v0alpha1_IdentityDisplayResults(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
@ -97,15 +97,35 @@ func schema_apimachinery_apis_identity_v0alpha1_IdentityDisplayList(ref common.R
Format: "",
},
},
"metadata": {
"keys": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "set",
},
},
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
Description: "Request keys used to lookup the display value",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"items": {
"display": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Description: "Matching items (the caller may need to remap from keys to results)",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
@ -116,11 +136,32 @@ func schema_apimachinery_apis_identity_v0alpha1_IdentityDisplayList(ref common.R
},
},
},
"invalidKeys": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "set",
},
},
SchemaProps: spec.SchemaProps{
Description: "Input keys that were not useable",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"keys", "display"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.IdentityDisplay", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
"github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1.IdentityDisplay"},
}
}

@ -1,3 +1,3 @@
API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1,IdentityDisplay,IdentityType
API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1,IdentityDisplay,LegacyID
API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1,IdentityDisplay,InternalID
API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1,TeamSpec,Title

@ -19,8 +19,7 @@ import (
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -47,13 +46,12 @@ type dashboardRow struct {
}
type dashboardSqlAccess struct {
sql db.DB
sql legacysql.NamespacedDBProvider
dialect sqltemplate.Dialect
sess *session.SessionDB
namespacer request.NamespaceMapper
dashStore dashboards.Store
provisioning provisioning.ProvisioningService
currentRV func(ctx context.Context) (int64, error)
currentRV legacysql.ResourceVersionLookup
// Typically one... the server wrapper
subscribers []chan *resource.WrittenEvent
@ -72,38 +70,15 @@ func NewDashboardAccess(sql db.DB,
fmt.Printf("ERROR: NO DIALECT")
}
sess := sql.GetSqlxSession()
currentRV := func(ctx context.Context) (int64, error) {
t := time.Now()
max := ""
err := sess.Get(ctx, &max, "SELECT MAX(updated) FROM dashboard")
if err == nil && max != "" {
t, _ = time.Parse(time.DateTime, max) // ignore null errors
}
return t.UnixMilli(), nil
}
if sql.GetDBType() == migrator.Postgres {
currentRV = func(ctx context.Context) (int64, error) {
max := time.Now()
_ = sess.Get(ctx, &max, "SELECT MAX(updated) FROM dashboard")
return max.UnixMilli(), nil
}
} else if sql.GetDBType() == migrator.MySQL {
currentRV = func(ctx context.Context) (int64, error) {
max := time.Now().UnixMilli()
_ = sess.Get(ctx, &max, "SELECT UNIX_TIMESTAMP(MAX(updated)) FROM dashboard;")
return max, nil
}
}
nssql := func(ctx context.Context) (db.DB, error) { return sql, nil }
return &dashboardSqlAccess{
sql: sql,
sess: sess,
sql: nssql,
dialect: dialect,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
currentRV: currentRV,
currentRV: legacysql.GetResourceVersionLookup(nssql, "dashboard", "updated"),
}
}
@ -134,7 +109,11 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, query *DashboardQuery)
// q = sqltemplate.RemoveEmptyLines(rawQuery)
// fmt.Printf(">>%s [%+v]", q, req.GetArgs())
rows, err := a.sess.Query(ctx, q, req.GetArgs()...)
db, err := a.sql(ctx)
if err != nil {
return nil, err
}
rows, err := db.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
if err != nil {
if rows != nil {
_ = rows.Close()

@ -0,0 +1,191 @@
package identity
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/api/dtos"
identity "github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/identity/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/setting"
errorsK8s "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
)
type displayREST struct {
store legacy.LegacyIdentityStore
}
var (
_ rest.Storage = (*displayREST)(nil)
_ rest.SingularNameProvider = (*displayREST)(nil)
_ rest.Connecter = (*displayREST)(nil)
_ rest.Scoper = (*displayREST)(nil)
_ rest.StorageMetadata = (*displayREST)(nil)
)
func newDisplayREST(store legacy.LegacyIdentityStore) *displayREST {
return &displayREST{store}
}
func (r *displayREST) New() runtime.Object {
return &identity.IdentityDisplayResults{}
}
func (r *displayREST) Destroy() {}
func (r *displayREST) NamespaceScoped() bool {
return true
}
func (r *displayREST) GetSingularName() string {
return "IdentityDisplay" // not actually used anywhere, but required by SingularNameProvider
}
func (r *displayREST) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
}
func (r *displayREST) ProducesObject(verb string) any {
return &identity.IdentityDisplayResults{}
}
func (r *displayREST) ConnectMethods() []string {
return []string{"GET"}
}
func (r *displayREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, "" // true means you can use the trailing path as a variable
}
// This will always have an empty app url
var fakeCfgForGravatar = &setting.Cfg{}
func (r *displayREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
// See: /pkg/services/apiserver/builder/helper.go#L34
// The name is set with a rewriter hack
if name != "name" {
return nil, errorsK8s.NewNotFound(schema.GroupResource{}, name)
}
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
keys := parseKeys(req.URL.Query()["key"])
users, err := r.store.GetDisplay(ctx, ns, legacy.GetUserDisplayQuery{
OrgID: ns.OrgID,
UIDs: keys.uids,
IDs: keys.ids,
})
if err != nil {
responder.Error(err)
return
}
rsp := &identity.IdentityDisplayResults{
Keys: keys.keys,
InvalidKeys: keys.invalid,
Display: make([]identity.IdentityDisplay, 0, len(users.Users)+len(keys.disp)+1),
}
for _, user := range users.Users {
disp := identity.IdentityDisplay{
IdentityType: claims.TypeUser,
Display: user.NameOrFallback(),
UID: user.UID,
}
if user.IsServiceAccount {
disp.IdentityType = claims.TypeServiceAccount
}
disp.AvatarURL = dtos.GetGravatarUrlWithDefault(fakeCfgForGravatar, user.Email, disp.Display)
rsp.Display = append(rsp.Display, disp)
}
// Append the constants here
if len(keys.disp) > 0 {
rsp.Display = append(rsp.Display, keys.disp...)
}
responder.Object(200, rsp)
}), nil
}
type dispKeys struct {
keys []string
uids []string
ids []int64
invalid []string
// For terminal keys, this is a constant
disp []identity.IdentityDisplay
}
func parseKeys(req []string) dispKeys {
keys := dispKeys{
uids: make([]string, 0, len(req)),
ids: make([]int64, 0, len(req)),
keys: req,
}
for _, key := range req {
idx := strings.Index(key, ":")
if idx > 0 {
t, err := claims.ParseType(key[0:idx])
if err != nil {
keys.invalid = append(keys.invalid, key)
continue
}
key = key[idx+1:]
switch t {
case claims.TypeAnonymous:
keys.disp = append(keys.disp, identity.IdentityDisplay{
IdentityType: t,
Display: "Anonymous",
AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)),
})
continue
case claims.TypeAPIKey:
keys.disp = append(keys.disp, identity.IdentityDisplay{
IdentityType: t,
UID: key,
Display: "API Key",
AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)),
})
continue
case claims.TypeProvisioning:
keys.disp = append(keys.disp, identity.IdentityDisplay{
IdentityType: t,
UID: "Provisioning",
Display: "Provisioning",
AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)),
})
continue
default:
// OK
}
}
// Try reading the internal ID
id, err := strconv.ParseInt(key, 10, 64)
if err == nil {
if id == 0 {
keys.disp = append(keys.disp, identity.IdentityDisplay{
IdentityType: claims.TypeUser,
UID: key,
Display: "System admin",
})
continue
}
keys.ids = append(keys.ids, id)
} else {
keys.uids = append(keys.uids, key)
}
}
return keys
}

@ -0,0 +1,186 @@
package legacy
import (
"context"
"fmt"
"text/template"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
var (
_ LegacyIdentityStore = (*legacySQLStore)(nil)
)
type legacySQLStore struct {
dialect sqltemplate.Dialect
sql legacysql.NamespacedDBProvider
teamsRV legacysql.ResourceVersionLookup
usersRV legacysql.ResourceVersionLookup
}
func NewLegacySQLStores(sql legacysql.NamespacedDBProvider) (LegacyIdentityStore, error) {
db, err := sql(context.Background())
if err != nil {
return nil, err
}
dialect := sqltemplate.DialectForDriver(string(db.GetDBType()))
if dialect == nil {
return nil, fmt.Errorf("unknown dialect")
}
return &legacySQLStore{
sql: sql,
dialect: dialect,
teamsRV: legacysql.GetResourceVersionLookup(sql, "team", "updated"),
usersRV: legacysql.GetResourceVersionLookup(sql, "user", "updated"),
}, nil
}
// ListTeams implements LegacyIdentityStore.
func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error) {
if query.Limit < 1 {
query.Limit = 50
}
limit := int(query.Limit)
query.Limit += 1 // for continue
query.OrgID = ns.OrgID
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero orgID")
}
req := sqlQueryListTeams{
SQLTemplate: sqltemplate.New(s.dialect),
Query: &query,
}
rawQuery, err := sqltemplate.Execute(sqlQueryTeams, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeams.Name(), err)
}
q := rawQuery
// fmt.Printf("%s // %v\n", rawQuery, req.GetArgs())
db, err := s.sql(ctx)
if err != nil {
return nil, err
}
res := &ListTeamResult{}
rows, err := db.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err == nil {
// id, uid, name, email, created, updated
var lastID int64
for rows.Next() {
t := team.Team{}
err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &t.Created, &t.Updated)
if err != nil {
return res, err
}
lastID = t.ID
res.Teams = append(res.Teams, t)
if len(res.Teams) > limit {
res.ContinueID = lastID
break
}
}
if query.UID == "" {
res.RV, err = s.teamsRV(ctx)
}
}
return res, err
}
// ListUsers implements LegacyIdentityStore.
func (s *legacySQLStore) ListUsers(ctx context.Context, ns claims.NamespaceInfo, query ListUserQuery) (*ListUserResult, error) {
if query.Limit < 1 {
query.Limit = 50
}
limit := int(query.Limit)
query.Limit += 1 // for continue
query.OrgID = ns.OrgID
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero orgID")
}
return s.queryUsers(ctx, sqlQueryUsers, sqlQueryListUsers{
SQLTemplate: sqltemplate.New(s.dialect),
Query: &query,
}, limit, query.UID != "")
}
func (s *legacySQLStore) queryUsers(ctx context.Context, t *template.Template, req sqltemplate.ArgsIface, limit int, getRV bool) (*ListUserResult, error) {
rawQuery, err := sqltemplate.Execute(t, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryUsers.Name(), err)
}
q := rawQuery
// fmt.Printf("%s // %v\n", rawQuery, req.GetArgs())
db, err := s.sql(ctx)
if err != nil {
return nil, err
}
res := &ListUserResult{}
rows, err := db.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err == nil {
var lastID int64
for rows.Next() {
u := user.User{}
err = rows.Scan(&u.OrgID, &u.ID, &u.UID, &u.Login, &u.Email, &u.Name,
&u.Created, &u.Updated, &u.IsServiceAccount, &u.IsDisabled, &u.IsAdmin,
)
if err != nil {
return res, err
}
lastID = u.ID
res.Users = append(res.Users, u)
if len(res.Users) > limit {
res.ContinueID = lastID
break
}
}
if getRV {
res.RV, err = s.usersRV(ctx)
}
}
return res, err
}
// GetUserTeams implements LegacyIdentityStore.
func (s *legacySQLStore) GetUserTeams(ctx context.Context, ns claims.NamespaceInfo, uid string) ([]team.Team, error) {
panic("unimplemented")
}
// GetDisplay implements LegacyIdentityStore.
func (s *legacySQLStore) GetDisplay(ctx context.Context, ns claims.NamespaceInfo, query GetUserDisplayQuery) (*ListUserResult, error) {
query.OrgID = ns.OrgID
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero orgID")
}
return s.queryUsers(ctx, sqlQueryDisplay, sqlQueryGetDisplay{
SQLTemplate: sqltemplate.New(s.dialect),
Query: &query,
}, 10000, false)
}

@ -0,0 +1,58 @@
package legacy
import (
"embed"
"fmt"
"text/template"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
// Templates setup.
var (
//go:embed *.sql
sqlTemplatesFS embed.FS
sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `*.sql`))
)
func mustTemplate(filename string) *template.Template {
if t := sqlTemplates.Lookup(filename); t != nil {
return t
}
panic(fmt.Sprintf("template file not found: %s", filename))
}
// Templates.
var (
sqlQueryTeams = mustTemplate("query_teams.sql")
sqlQueryUsers = mustTemplate("query_users.sql")
sqlQueryDisplay = mustTemplate("query_display.sql")
)
type sqlQueryListUsers struct {
*sqltemplate.SQLTemplate
Query *ListUserQuery
}
func (r sqlQueryListUsers) Validate() error {
return nil // TODO
}
type sqlQueryListTeams struct {
*sqltemplate.SQLTemplate
Query *ListTeamQuery
}
func (r sqlQueryListTeams) Validate() error {
return nil // TODO
}
type sqlQueryGetDisplay struct {
*sqltemplate.SQLTemplate
Query *GetUserDisplayQuery
}
func (r sqlQueryGetDisplay) Validate() error {
return nil // TODO
}

@ -0,0 +1,207 @@
package legacy
import (
"embed"
"os"
"path/filepath"
"testing"
"text/template"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
//go:embed testdata/*
var testdataFS embed.FS
func testdata(t *testing.T, filename string) []byte {
t.Helper()
b, err := testdataFS.ReadFile(`testdata/` + filename)
if err != nil {
writeTestData(filename, "<empty>")
assert.Fail(t, "missing test file")
}
return b
}
func writeTestData(filename, value string) {
_ = os.WriteFile(filepath.Join("testdata", filename), []byte(value), 0777)
}
func TestQueries(t *testing.T) {
t.Parallel()
// Check each dialect
dialects := []sqltemplate.Dialect{
sqltemplate.MySQL,
sqltemplate.SQLite,
sqltemplate.PostgreSQL,
}
// Each template has one or more test cases, each identified with a
// descriptive name (e.g. "happy path", "error twiddling the frobb"). Each
// of them will test that for the same input data they must produce a result
// that will depend on the Dialect. Expected queries should be defined in
// separate files in the testdata directory. This improves the testing
// experience by separating test data from test code, since mixing both
// tends to make it more difficult to reason about what is being done,
// especially as we want testing code to scale and make it easy to add
// tests.
type (
testCase = struct {
Name string
// Data should be the struct passed to the template.
Data sqltemplate.SQLTemplateIface
}
)
// Define tests cases. Most templates are trivial and testing that they
// generate correct code for a single Dialect is fine, since the one thing
// that always changes is how SQL placeholder arguments are passed (most
// Dialects use `?` while PostgreSQL uses `$1`, `$2`, etc.), and that is
// something that should be tested in the Dialect implementation instead of
// here. We will ask to have at least one test per SQL template, and we will
// lean to test MySQL. Templates containing branching (conditionals, loops,
// etc.) should be exercised at least once in each of their branches.
//
// NOTE: in the Data field, make sure to have pointers populated to simulate
// data is set as it would be in a real request. The data being correctly
// populated in each case should be tested in integration tests, where the
// data will actually flow to and from a real database. In this tests we
// only care about producing the correct SQL.
testCases := map[*template.Template][]*testCase{
sqlQueryTeams: {
{
Name: "teams_uid",
Data: &sqlQueryListTeams{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &ListTeamQuery{
UID: "abc",
},
},
},
{
Name: "teams_page_1",
Data: &sqlQueryListTeams{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &ListTeamQuery{
Limit: 5,
},
},
},
{
Name: "teams_page_2",
Data: &sqlQueryListTeams{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &ListTeamQuery{
ContinueID: 1,
Limit: 2,
},
},
},
},
sqlQueryUsers: {
{
Name: "users_uid",
Data: &sqlQueryListUsers{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &ListUserQuery{
UID: "abc",
},
},
},
{
Name: "users_page_1",
Data: &sqlQueryListUsers{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &ListUserQuery{
Limit: 5,
},
},
},
{
Name: "users_page_2",
Data: &sqlQueryListUsers{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &ListUserQuery{
ContinueID: 1,
Limit: 2,
},
},
},
},
sqlQueryDisplay: {
{
Name: "display_uids",
Data: &sqlQueryGetDisplay{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &GetUserDisplayQuery{
OrgID: 2,
UIDs: []string{"a", "b"},
},
},
},
{
Name: "display_ids",
Data: &sqlQueryGetDisplay{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &GetUserDisplayQuery{
OrgID: 2,
IDs: []int64{1, 2},
},
},
},
{
Name: "display_ids_uids",
Data: &sqlQueryGetDisplay{
SQLTemplate: new(sqltemplate.SQLTemplate),
Query: &GetUserDisplayQuery{
OrgID: 2,
UIDs: []string{"a", "b"},
IDs: []int64{1, 2},
},
},
},
},
}
// Execute test cases
for tmpl, tcs := range testCases {
t.Run(tmpl.Name(), func(t *testing.T) {
t.Parallel()
for _, tc := range tcs {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
for _, dialect := range dialects {
filename := dialect.DialectName() + "__" + tc.Name + ".sql"
t.Run(filename, func(t *testing.T) {
// not parallel because we're sharing tc.Data, not
// worth it deep cloning
expectedQuery := string(testdata(t, filename))
//expectedQuery := sqltemplate.FormatSQL(rawQuery)
tc.Data.SetDialect(dialect)
err := tc.Data.Validate()
require.NoError(t, err)
got, err := sqltemplate.Execute(tmpl, tc.Data)
require.NoError(t, err)
got = sqltemplate.RemoveEmptyLines(got)
if diff := cmp.Diff(expectedQuery, got); diff != "" {
writeTestData(filename, got)
t.Errorf("%s: %s", tc.Name, diff)
}
})
}
})
}
})
}
}

@ -0,0 +1,13 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM {{ .Ident "user" }} as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = {{ .Arg .Query.OrgID }} AND ( 1=2
{{ if .Query.UIDs }}
OR uid IN ({{ .ArgList .Query.UIDs }})
{{ end }}
{{ if .Query.IDs }}
OR u.id IN ({{ .ArgList .Query.IDs }})
{{ end }}
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,11 @@
SELECT id, uid, name, email, created, updated
FROM {{ .Ident "team" }}
WHERE org_id = {{ .Arg .Query.OrgID }}
{{ if .Query.UID }}
AND uid = {{ .Arg .Query.UID }}
{{ end }}
{{ if .Query.ContinueID }}
AND id > {{ .Arg .Query.ContinueID }}
{{ end }}
ORDER BY id asc
LIMIT {{ .Arg .Query.Limit }}

@ -0,0 +1,13 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM {{ .Ident "user" }} as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = {{ .Arg .Query.OrgID }}
AND u.is_service_account = {{ .Arg .Query.IsServiceAccount }}
{{ if .Query.UID }}
AND uid = {{ .Arg .Query.UID }}
{{ end }}
{{ if .Query.ContinueID }}
AND id > {{ .Arg .Query.ContinueID }}
{{ end }}
ORDER BY u.id asc
LIMIT {{ .Arg .Query.Limit }}

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM `user` as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ? AND ( 1=2
OR u.id IN (?, ?)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,9 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM `user` as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ? AND ( 1=2
OR uid IN (?, ?)
OR u.id IN (?, ?)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM `user` as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ? AND ( 1=2
OR uid IN (?, ?)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,5 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = ?
ORDER BY id asc
LIMIT ?

@ -0,0 +1,6 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = ?
AND id > ?
ORDER BY id asc
LIMIT ?

@ -0,0 +1,6 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = ?
AND uid = ?
ORDER BY id asc
LIMIT ?

@ -0,0 +1,7 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM `user` as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ?
AND u.is_service_account = ?
ORDER BY u.id asc
LIMIT ?

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM `user` as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ?
AND u.is_service_account = ?
AND id > ?
ORDER BY u.id asc
LIMIT ?

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM `user` as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ?
AND u.is_service_account = ?
AND uid = ?
ORDER BY u.id asc
LIMIT ?

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = $1 AND ( 1=2
OR u.id IN ($2, $3)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,9 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = $1 AND ( 1=2
OR uid IN ($2, $3)
OR u.id IN ($4, $5)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = $1 AND ( 1=2
OR uid IN ($2, $3)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,5 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = $1
ORDER BY id asc
LIMIT $2

@ -0,0 +1,6 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = $1
AND id > $2
ORDER BY id asc
LIMIT $3

@ -0,0 +1,6 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = $1
AND uid = $2
ORDER BY id asc
LIMIT $3

@ -0,0 +1,7 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = $1
AND u.is_service_account = $2
ORDER BY u.id asc
LIMIT $3

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = $1
AND u.is_service_account = $2
AND id > $3
ORDER BY u.id asc
LIMIT $4

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = $1
AND u.is_service_account = $2
AND uid = $3
ORDER BY u.id asc
LIMIT $4

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ? AND ( 1=2
OR u.id IN (?, ?)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,9 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ? AND ( 1=2
OR uid IN (?, ?)
OR u.id IN (?, ?)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ? AND ( 1=2
OR uid IN (?, ?)
)
ORDER BY u.id asc
LIMIT 500

@ -0,0 +1,5 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = ?
ORDER BY id asc
LIMIT ?

@ -0,0 +1,6 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = ?
AND id > ?
ORDER BY id asc
LIMIT ?

@ -0,0 +1,6 @@
SELECT id, uid, name, email, created, updated
FROM "team"
WHERE org_id = ?
AND uid = ?
ORDER BY id asc
LIMIT ?

@ -0,0 +1,7 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ?
AND u.is_service_account = ?
ORDER BY u.id asc
LIMIT ?

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ?
AND u.is_service_account = ?
AND id > ?
ORDER BY u.id asc
LIMIT ?

@ -0,0 +1,8 @@
SELECT org_user.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin
FROM "user" as u JOIN org_user ON u.id = org_user.user_id
WHERE org_user.org_id = ?
AND u.is_service_account = ?
AND uid = ?
ORDER BY u.id asc
LIMIT ?

@ -0,0 +1,50 @@
package legacy
import (
"context"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
type ListUserQuery struct {
OrgID int64
UID string
ContinueID int64 // ContinueID
Limit int64
IsServiceAccount bool
}
type ListUserResult struct {
Users []user.User
ContinueID int64
RV int64
}
type ListTeamQuery struct {
OrgID int64
UID string
ContinueID int64 // ContinueID
Limit int64
}
type ListTeamResult struct {
Teams []team.Team
ContinueID int64
RV int64
}
type GetUserDisplayQuery struct {
OrgID int64
UIDs []string
IDs []int64
}
// In every case, RBAC should be applied before calling, or before returning results to the requester
type LegacyIdentityStore interface {
ListUsers(ctx context.Context, ns claims.NamespaceInfo, query ListUserQuery) (*ListUserResult, error)
ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error)
GetDisplay(ctx context.Context, ns claims.NamespaceInfo, query GetUserDisplayQuery) (*ListUserResult, error)
GetUserTeams(ctx context.Context, ns claims.NamespaceInfo, uid string) ([]team.Team, error)
}

@ -5,16 +5,16 @@ import (
"fmt"
"strconv"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
identity "github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/identity/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/user"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
var (
@ -26,7 +26,7 @@ var (
)
type legacyServiceAccountStorage struct {
service user.Service
service legacy.LegacyIdentityStore
tableConverter rest.TableConvertor
resourceInfo common.ResourceInfo
}
@ -58,7 +58,7 @@ func (s *legacyServiceAccountStorage) List(ctx context.Context, options *interna
if err != nil {
return nil, err
}
query := &user.ListUsersCommand{
query := legacy.ListUserQuery{
OrgID: ns.OrgID,
Limit: options.Limit,
IsServiceAccount: true,
@ -70,13 +70,14 @@ func (s *legacyServiceAccountStorage) List(ctx context.Context, options *interna
}
}
found, err := s.service.List(ctx, query)
found, err := s.service.ListUsers(ctx, ns, query)
if err != nil {
return nil, err
}
list := &identity.ServiceAccountList{}
for _, item := range found.Users {
list.Items = append(list.Items, *toSAItem(item, ns.Value))
list.Items = append(list.Items, *toSAItem(&item, ns.Value))
}
if found.ContinueID > 0 {
list.ListMeta.Continue = strconv.FormatInt(found.ContinueID, 10)
@ -116,15 +117,18 @@ func (s *legacyServiceAccountStorage) Get(ctx context.Context, name string, opti
if err != nil {
return nil, err
}
found, err := s.service.GetByUID(ctx, &user.GetUserByUIDQuery{
OrgID: ns.OrgID,
UID: name,
})
query := legacy.ListUserQuery{
OrgID: ns.OrgID,
Limit: 1,
IsServiceAccount: true,
}
found, err := s.service.ListUsers(ctx, ns, query)
if found == nil || err != nil {
return nil, s.resourceInfo.NewNotFound(name)
}
if !found.IsServiceAccount {
return nil, s.resourceInfo.NewNotFound(name) // looking up the wrong type
if len(found.Users) < 1 {
return nil, s.resourceInfo.NewNotFound(name)
}
return toUserItem(found, ns.Value), nil
return toSAItem(&found.Users[0], ns.Value), nil
}

@ -4,16 +4,17 @@ import (
"context"
"strconv"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/authlib/claims"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
identity "github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/identity/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/team"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
var (
@ -25,7 +26,7 @@ var (
)
type legacyTeamStorage struct {
service team.Service
service legacy.LegacyIdentityStore
tableConverter rest.TableConvertor
resourceInfo common.ResourceInfo
}
@ -52,20 +53,25 @@ func (s *legacyTeamStorage) ConvertToTable(ctx context.Context, object runtime.O
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *legacyTeamStorage) doList(ctx context.Context, ns string, query *team.ListTeamsCommand) (*identity.TeamList, error) {
func (s *legacyTeamStorage) doList(ctx context.Context, ns claims.NamespaceInfo, query legacy.ListTeamQuery) (*identity.TeamList, error) {
if query.Limit < 1 {
query.Limit = 100
}
teams, err := s.service.ListTeams(ctx, query)
rsp, err := s.service.ListTeams(ctx, ns, query)
if err != nil {
return nil, err
}
list := &identity.TeamList{}
for _, team := range teams {
list := &identity.TeamList{
ListMeta: metav1.ListMeta{
ResourceVersion: strconv.FormatInt(rsp.RV, 10),
},
}
for _, team := range rsp.Teams {
item := identity.Team{
ObjectMeta: metav1.ObjectMeta{
Name: team.UID,
Namespace: ns,
Namespace: ns.Value,
CreationTimestamp: metav1.NewTime(team.Created),
ResourceVersion: strconv.FormatInt(team.Updated.UnixMilli(), 10),
},
@ -85,6 +91,9 @@ func (s *legacyTeamStorage) doList(ctx context.Context, ns string, query *team.L
})
list.Items = append(list.Items, item)
}
if rsp.ContinueID > 0 {
list.ListMeta.Continue = strconv.FormatInt(rsp.ContinueID, 10)
}
return list, nil
}
@ -93,10 +102,17 @@ func (s *legacyTeamStorage) List(ctx context.Context, options *internalversion.L
if err != nil {
return nil, err
}
return s.doList(ctx, ns.Value, &team.ListTeamsCommand{
Limit: int(options.Limit),
query := legacy.ListTeamQuery{
OrgID: ns.OrgID,
})
Limit: options.Limit,
}
if options.Continue != "" {
query.ContinueID, err = strconv.ParseInt(options.Continue, 10, 64)
if err != nil {
return nil, err
}
}
return s.doList(ctx, ns, query)
}
func (s *legacyTeamStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
@ -104,9 +120,9 @@ func (s *legacyTeamStorage) Get(ctx context.Context, name string, options *metav
if err != nil {
return nil, err
}
rsp, err := s.doList(ctx, ns.Value, &team.ListTeamsCommand{
Limit: 1,
rsp, err := s.doList(ctx, ns, legacy.ListTeamQuery{
OrgID: ns.OrgID,
Limit: 1,
UID: name,
})
if err != nil {
@ -117,3 +133,28 @@ func (s *legacyTeamStorage) Get(ctx context.Context, name string, options *metav
}
return nil, s.resourceInfo.NewNotFound(name)
}
func asTeam(team *team.Team, ns string) (*identity.Team, error) {
item := &identity.Team{
ObjectMeta: metav1.ObjectMeta{
Name: team.UID,
Namespace: ns,
CreationTimestamp: metav1.NewTime(team.Created),
ResourceVersion: strconv.FormatInt(team.Updated.UnixMilli(), 10),
},
Spec: identity.TeamSpec{
Title: team.Name,
Email: team.Email,
},
}
meta, err := utils.MetaAccessor(item)
if err != nil {
return nil, err
}
meta.SetUpdatedTimestamp(&team.Updated)
meta.SetOriginInfo(&utils.ResourceOriginInfo{
Name: "SQL",
Path: strconv.FormatInt(team.ID, 10),
})
return item, nil
}

@ -0,0 +1,87 @@
package identity
import (
"context"
"net/http"
identity "github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/identity/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
type userTeamsREST struct {
logger log.Logger
store legacy.LegacyIdentityStore
}
var (
_ rest.Storage = (*userTeamsREST)(nil)
_ rest.SingularNameProvider = (*userTeamsREST)(nil)
_ rest.Connecter = (*userTeamsREST)(nil)
_ rest.Scoper = (*userTeamsREST)(nil)
_ rest.StorageMetadata = (*userTeamsREST)(nil)
)
func newUserTeamsREST(store legacy.LegacyIdentityStore) *userTeamsREST {
return &userTeamsREST{
logger: log.New("user teams"),
store: store,
}
}
func (r *userTeamsREST) New() runtime.Object {
return &identity.TeamList{}
}
func (r *userTeamsREST) Destroy() {}
func (r *userTeamsREST) NamespaceScoped() bool {
return true
}
func (r *userTeamsREST) GetSingularName() string {
return "TeamList" // Used for the
}
func (r *userTeamsREST) ProducesMIMETypes(verb string) []string {
return []string{"application/json"} // and parquet!
}
func (r *userTeamsREST) ProducesObject(verb string) interface{} {
return &identity.TeamList{}
}
func (r *userTeamsREST) ConnectMethods() []string {
return []string{"GET"}
}
func (r *userTeamsREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, "" // true means you can use the trailing path as a variable
}
func (r *userTeamsREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
teams, err := r.store.GetUserTeams(ctx, ns, name)
if err != nil {
return nil, err
}
return http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
list := &identity.TeamList{}
for _, team := range teams {
t, err := asTeam(&team, ns.Value)
if err != nil {
responder.Error(err)
return
}
list.Items = append(list.Items, *t)
}
responder.Object(200, list)
}), nil
}

@ -5,16 +5,16 @@ import (
"fmt"
"strconv"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
identity "github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/identity/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/user"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
var (
@ -26,7 +26,7 @@ var (
)
type legacyUserStorage struct {
service user.Service
service legacy.LegacyIdentityStore
tableConverter rest.TableConvertor
resourceInfo common.ResourceInfo
}
@ -58,9 +58,10 @@ func (s *legacyUserStorage) List(ctx context.Context, options *internalversion.L
if err != nil {
return nil, err
}
query := &user.ListUsersCommand{
OrgID: ns.OrgID,
Limit: options.Limit,
query := legacy.ListUserQuery{
OrgID: ns.OrgID,
Limit: options.Limit,
IsServiceAccount: false,
}
if options.Continue != "" {
query.ContinueID, err = strconv.ParseInt(options.Continue, 10, 64)
@ -69,13 +70,14 @@ func (s *legacyUserStorage) List(ctx context.Context, options *internalversion.L
}
}
found, err := s.service.List(ctx, query)
found, err := s.service.ListUsers(ctx, ns, query)
if err != nil {
return nil, err
}
list := &identity.UserList{}
for _, item := range found.Users {
list.Items = append(list.Items, *toUserItem(item, ns.Value))
list.Items = append(list.Items, *toUserItem(&item, ns.Value))
}
if found.ContinueID > 0 {
list.ListMeta.Continue = strconv.FormatInt(found.ContinueID, 10)
@ -116,15 +118,18 @@ func (s *legacyUserStorage) Get(ctx context.Context, name string, options *metav
if err != nil {
return nil, err
}
found, err := s.service.GetByUID(ctx, &user.GetUserByUIDQuery{
OrgID: ns.OrgID,
UID: name,
})
query := legacy.ListUserQuery{
OrgID: ns.OrgID,
Limit: 1,
IsServiceAccount: false,
}
found, err := s.service.ListUsers(ctx, ns, query)
if found == nil || err != nil {
return nil, s.resourceInfo.NewNotFound(name)
}
if found.IsServiceAccount {
return nil, s.resourceInfo.NewNotFound(name) // looking up the wrong type
if len(found.Users) < 1 {
return nil, s.resourceInfo.NewNotFound(name)
}
return toUserItem(found, ns.Value), nil
return toUserItem(&found.Users[0], ns.Value), nil
}

@ -16,37 +16,42 @@ import (
identity "github.com/grafana/grafana/pkg/apimachinery/apis/identity/v0alpha1"
identityapi "github.com/grafana/grafana/pkg/apimachinery/identity"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/identity/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
var _ builder.APIGroupBuilder = (*IdentityAPIBuilder)(nil)
// This is used just so wire has something unique to return
type IdentityAPIBuilder struct {
svcTeam team.Service
svcUser user.Service
Store legacy.LegacyIdentityStore
}
func RegisterAPIService(
features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
svcTeam team.Service,
svcUser user.Service,
) *IdentityAPIBuilder {
// svcTeam team.Service,
// svcUser user.Service,
sql db.DB,
) (*IdentityAPIBuilder, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil // skip registration unless opting into experimental apis
return nil, nil // skip registration unless opting into experimental apis
}
store, err := legacy.NewLegacySQLStores(func(context.Context) (db.DB, error) {
return sql, nil
})
if err != nil {
return nil, err
}
builder := &IdentityAPIBuilder{
svcTeam: svcTeam,
svcUser: svcUser,
Store: store,
}
apiregistration.RegisterAPI(builder)
return builder
return builder, nil
}
func (b *IdentityAPIBuilder) GetGroupVersion() schema.GroupVersion {
@ -84,7 +89,7 @@ func (b *IdentityAPIBuilder) GetAPIGroupInfo(
team := identity.TeamResourceInfo
teamStore := &legacyTeamStorage{
service: b.svcTeam,
service: b.Store,
resourceInfo: team,
tableConverter: team.TableConverter(),
}
@ -92,20 +97,24 @@ func (b *IdentityAPIBuilder) GetAPIGroupInfo(
user := identity.UserResourceInfo
userStore := &legacyUserStorage{
service: b.svcUser,
service: b.Store,
resourceInfo: user,
tableConverter: user.TableConverter(),
}
storage[user.StoragePath()] = userStore
storage[user.StoragePath("teams")] = newUserTeamsREST(b.Store)
sa := identity.ServiceAccountResourceInfo
saStore := &legacyServiceAccountStorage{
service: b.svcUser,
service: b.Store,
resourceInfo: sa,
tableConverter: sa.TableConverter(),
}
storage[sa.StoragePath()] = saStore
// The display endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter
storage["display"] = newDisplayREST(b.Store)
apiGroupInfo.VersionedResourcesStorageMap[identity.VERSION] = storage
return &apiGroupInfo, nil
}

@ -41,6 +41,11 @@ func (auth orgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut
return authorizer.DecisionNoOpinion, "", nil
}
// Grafana super admins can see things in every org
if signedInUser.GetIsGrafanaAdmin() {
return authorizer.DecisionNoOpinion, "", nil
}
if info.OrgID == -1 {
return authorizer.DecisionDeny, "org id is required", nil
}

@ -43,6 +43,12 @@ var pathRewriters = []filters.PathRewriter{
return matches[1] + "/name" // connector requires a name
},
},
{
Pattern: regexp.MustCompile(`(/apis/identity.grafana.app/v0alpha1/namespaces/.*/display$)`),
ReplaceFunc: func(matches []string) string {
return matches[1] + "/name" // connector requires a name
},
},
}
func SetupConfig(

@ -90,13 +90,6 @@ type SearchTeamsQuery struct {
HiddenUsers map[string]struct{}
}
type ListTeamsCommand struct {
Limit int
Start int
OrgID int64
UID string
}
type TeamDTO struct {
ID int64 `json:"id" xorm:"id"`
UID string `json:"uid" xorm:"uid"`

@ -8,7 +8,6 @@ type Service interface {
CreateTeam(ctx context.Context, name, email string, orgID int64) (Team, error)
UpdateTeam(ctx context.Context, cmd *UpdateTeamCommand) error
DeleteTeam(ctx context.Context, cmd *DeleteTeamCommand) error
ListTeams(ctx context.Context, query *ListTeamsCommand) ([]*Team, error)
SearchTeams(ctx context.Context, query *SearchTeamsQuery) (SearchTeamQueryResult, error)
GetTeamByID(ctx context.Context, query *GetTeamByIDQuery) (*TeamDTO, error)
GetTeamsByUser(ctx context.Context, query *GetTeamsByUserQuery) ([]*TeamDTO, error)

@ -20,7 +20,6 @@ type store interface {
Create(name, email string, orgID int64) (team.Team, error)
Update(ctx context.Context, cmd *team.UpdateTeamCommand) error
Delete(ctx context.Context, cmd *team.DeleteTeamCommand) error
ListTeams(ctx context.Context, query *team.ListTeamsCommand) ([]*team.Team, error)
Search(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error)
GetByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error)
GetByUser(ctx context.Context, query *team.GetTeamsByUserQuery) ([]*team.TeamDTO, error)
@ -268,21 +267,6 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) (
return queryResult, nil
}
func (ss *xormStore) ListTeams(ctx context.Context, query *team.ListTeamsCommand) ([]*team.Team, error) {
results := make([]*team.Team, 0)
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
q := sess.Table("team")
q.Where("team.org_id=?", query.OrgID)
if query.UID != "" {
q.Where("team.uid=?", query.UID)
}
q.Limit(query.Limit, query.Start)
return q.Find(&results)
})
return results, err
}
func (ss *xormStore) GetByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) {
var queryResult *team.TeamDTO
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {

@ -51,14 +51,6 @@ func (s *Service) DeleteTeam(ctx context.Context, cmd *team.DeleteTeamCommand) e
return s.store.Delete(ctx, cmd)
}
func (s *Service) ListTeams(ctx context.Context, query *team.ListTeamsCommand) ([]*team.Team, error) {
ctx, span := s.tracer.Start(ctx, "team.ListTeams", trace.WithAttributes(
attribute.Int64("orgID", query.OrgID),
))
defer span.End()
return s.store.ListTeams(ctx, query)
}
func (s *Service) SearchTeams(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) {
ctx, span := s.tracer.Start(ctx, "team.SearchTeams", trace.WithAttributes(
attribute.Int64("orgID", query.OrgID),

@ -36,10 +36,6 @@ func (s *FakeService) SearchTeams(ctx context.Context, query *team.SearchTeamsQu
return team.SearchTeamQueryResult{}, s.ExpectedError
}
func (s *FakeService) ListTeams(ctx context.Context, query *team.ListTeamsCommand) ([]*team.Team, error) {
return nil, s.ExpectedError
}
func (s *FakeService) GetTeamByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) {
return s.ExpectedTeamDTO, s.ExpectedError
}

@ -98,13 +98,6 @@ type UpdateUserLastSeenAtCommand struct {
OrgID int64
}
type ListUsersCommand struct {
OrgID int64
Limit int64
ContinueID int64
IsServiceAccount bool
}
type ListUserResult struct {
Users []*User
ContinueID int64

@ -16,7 +16,6 @@ type Service interface {
GetByUID(context.Context, *GetUserByUIDQuery) (*User, error)
GetByLogin(context.Context, *GetUserByLoginQuery) (*User, error)
GetByEmail(context.Context, *GetUserByEmailQuery) (*User, error)
List(context.Context, *ListUsersCommand) (*ListUserResult, error)
Update(context.Context, *UpdateUserCommand) error
UpdateLastSeenAt(context.Context, *UpdateUserLastSeenAtCommand) error
GetSignedInUser(context.Context, *GetSignedInUserQuery) (*SignedInUser, error)

@ -23,7 +23,6 @@ type store interface {
GetByUID(ctx context.Context, orgId int64, uid string) (*user.User, error)
GetByLogin(context.Context, *user.GetUserByLoginQuery) (*user.User, error)
GetByEmail(context.Context, *user.GetUserByEmailQuery) (*user.User, error)
List(context.Context, *user.ListUsersCommand) (*user.ListUserResult, error)
Delete(context.Context, int64) error
LoginConflict(ctx context.Context, login, email string) error
Update(context.Context, *user.UpdateUserCommand) error
@ -579,40 +578,6 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*
return &result, err
}
func (ss *sqlStore) List(ctx context.Context, query *user.ListUsersCommand) (*user.ListUserResult, error) {
limit := int(query.Limit)
if limit <= 0 {
limit = 25
}
result := &user.ListUserResult{
Users: make([]*user.User, 0),
}
max := ""
err := ss.db.WithDbSession(ctx, func(dbSess *db.Session) error {
sess := dbSess.Table("user")
sess.Where("id >= ? AND is_service_account = ?", query.ContinueID, query.IsServiceAccount)
err := sess.OrderBy("id asc").Limit(limit + 1).Find(&result.Users)
if err != nil {
return err
}
// Set the revision version
_, err = dbSess.Table("user").Select("MAX(updated)").Get(&max)
return err
})
if max != "" {
t, err := time.Parse(time.DateTime, max)
if err == nil {
result.RV = t.UnixMilli()
}
}
if len(result.Users) > limit {
result.ContinueID = result.Users[limit].ID
result.Users = result.Users[:limit]
}
return result, err
}
func setOptional[T any](v *T, add func(v T)) {
if v != nil {
add(*v)

@ -378,15 +378,6 @@ func (s *Service) getSignedInUser(ctx context.Context, query *user.GetSignedInUs
return usr, err
}
func (s *Service) List(ctx context.Context, query *user.ListUsersCommand) (*user.ListUserResult, error) {
ctx, span := s.tracer.Start(ctx, "user.List", trace.WithAttributes(
attribute.Int64("orgID", query.OrgID),
))
defer span.End()
return s.store.List(ctx, query)
}
func (s *Service) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) {
ctx, span := s.tracer.Start(ctx, "user.Search", trace.WithAttributes(
attribute.Int64("orgID", query.OrgID),

@ -331,10 +331,6 @@ func (f *FakeUserStore) Search(ctx context.Context, query *user.SearchUsersQuery
return f.ExpectedSearchUserQueryResult, f.ExpectedError
}
func (f *FakeUserStore) List(ctx context.Context, query *user.ListUsersCommand) (*user.ListUserResult, error) {
return nil, f.ExpectedError
}
func (f *FakeUserStore) Count(ctx context.Context) (int64, error) {
return 0, nil
}

@ -98,10 +98,6 @@ func (f *FakeUserService) Search(ctx context.Context, query *user.SearchUsersQue
return &f.ExpectedSearchUsers, f.ExpectedError
}
func (f *FakeUserService) List(ctx context.Context, query *user.ListUsersCommand) (*user.ListUserResult, error) {
return &f.ExpectedListUsers, f.ExpectedError
}
func (f *FakeUserService) BatchDisableUsers(ctx context.Context, cmd *user.BatchDisableUsersCommand) error {
if f.BatchDisableUsersFn != nil {
return f.BatchDisableUsersFn(ctx, cmd)

@ -310,36 +310,6 @@ func (_m *MockService) GetUsageStats(ctx context.Context) map[string]interface{}
return r0
}
// List provides a mock function with given fields: _a0, _a1
func (_m *MockService) List(_a0 context.Context, _a1 *user.ListUsersCommand) (*user.ListUserResult, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for List")
}
var r0 *user.ListUserResult
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *user.ListUsersCommand) (*user.ListUserResult, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, *user.ListUsersCommand) *user.ListUserResult); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*user.ListUserResult)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *user.ListUsersCommand) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Search provides a mock function with given fields: _a0, _a1
func (_m *MockService) Search(_a0 context.Context, _a1 *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) {
ret := _m.Called(_a0, _a1)

@ -0,0 +1,8 @@
# Legacy SQL
As we transition from our internal sql store towards unified storage, we can sometimes use existing
services to implement a k8s compatible storage that can then dual write into unified storage.
However sometimes it is more efficient and cleaner to write explicit SQL commands designed for this goal.
This package provides some helper functions to make this easier.

@ -0,0 +1,51 @@
package legacysql
import (
"context"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// The database may depend on the request context
type NamespacedDBProvider func(ctx context.Context) (db.DB, error)
// Get the list RV from the maximum updated time
type ResourceVersionLookup = func(ctx context.Context) (int64, error)
// Get a resource version from the max value the updated field
func GetResourceVersionLookup(sql NamespacedDBProvider, table string, column string) ResourceVersionLookup {
return func(ctx context.Context) (int64, error) {
db, err := sql(ctx)
if err != nil {
return 1, err
}
table = db.GetDialect().Quote(table)
column = db.GetDialect().Quote(column)
switch db.GetDBType() {
case migrator.Postgres:
max := time.Now()
err := db.GetSqlxSession().Get(ctx, &max, "SELECT MAX("+column+") FROM "+table)
if err != nil {
return 1, nil
}
return max.UnixMilli(), nil
case migrator.MySQL:
max := int64(1)
_ = db.GetSqlxSession().Get(ctx, &max, "SELECT UNIX_TIMESTAMP(MAX("+column+")) FROM "+table)
return max, nil
default:
// fallthrough to string version
}
max := ""
err = db.GetSqlxSession().Get(ctx, &max, "SELECT MAX("+column+") FROM "+table)
if err == nil && max != "" {
t, _ := time.Parse(time.DateTime, max) // ignore null errors
return t.UnixMilli(), nil
}
return 1, nil
}
}

@ -38,6 +38,7 @@ import (
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/team/teamimpl"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/userimpl"
@ -49,7 +50,7 @@ const Org1 = "Org1"
type K8sTestHelper struct {
t *testing.T
env server.TestEnv
namespacer request.NamespaceMapper
Namespacer request.NamespaceMapper
Org1 OrgUsers // default
OrgB OrgUsers // some other id
@ -66,7 +67,7 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
c := &K8sTestHelper{
env: *env,
t: t,
namespacer: request.GetNamespaceMapper(nil),
Namespacer: request.GetNamespaceMapper(nil),
}
c.Org1 = c.createTestUsers(Org1)
@ -120,7 +121,7 @@ func (c *K8sTestHelper) GetResourceClient(args ResourceClientArgs) *K8sResourceC
c.t.Helper()
if args.Namespace == "" {
args.Namespace = c.namespacer(args.User.Identity.GetOrgID())
args.Namespace = c.Namespacer(args.User.Identity.GetOrgID())
}
client, err := dynamic.NewForConfig(args.User.NewRestConfig())
@ -147,8 +148,46 @@ func (c *K8sTestHelper) AsStatusError(err error) *errors.StatusError {
return statusError
}
func (c *K8sResourceClient) SanitizeJSONList(v *unstructured.UnstructuredList, replaceMeta ...string) string {
c.t.Helper()
clean := &unstructured.UnstructuredList{}
for _, item := range v.Items {
copy := c.sanitizeObject(&item, replaceMeta...)
clean.Items = append(clean.Items, *copy)
}
out, err := json.MarshalIndent(clean, "", " ")
require.NoError(c.t, err)
return string(out)
}
func (c *K8sResourceClient) SpecJSON(v *unstructured.UnstructuredList) string {
c.t.Helper()
clean := []any{}
for _, item := range v.Items {
clean = append(clean, item.Object["spec"])
}
out, err := json.MarshalIndent(clean, "", " ")
require.NoError(c.t, err)
return string(out)
}
// remove the meta keys that are expected to change each time
func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured) string {
func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured, replaceMeta ...string) string {
c.t.Helper()
copy := c.sanitizeObject(v)
out, err := json.MarshalIndent(copy, "", " ")
// fmt.Printf("%s", out)
require.NoError(c.t, err)
return string(out)
}
// remove the meta keys that are expected to change each time
func (c *K8sResourceClient) sanitizeObject(v *unstructured.Unstructured, replaceMeta ...string) *unstructured.Unstructured {
c.t.Helper()
deep := v.DeepCopy()
@ -170,24 +209,24 @@ func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured) string {
meta, ok := copy["metadata"].(map[string]any)
require.True(c.t, ok)
replaceMeta := []string{"creationTimestamp", "resourceVersion", "uid"}
replaceMeta = append(replaceMeta, "creationTimestamp", "resourceVersion", "uid")
for _, key := range replaceMeta {
old, ok := meta[key]
require.True(c.t, ok)
require.NotEmpty(c.t, old)
meta[key] = fmt.Sprintf("${%s}", key)
if ok {
require.NotEmpty(c.t, old)
meta[key] = fmt.Sprintf("${%s}", key)
}
}
out, err := json.MarshalIndent(copy, "", " ")
// fmt.Printf("%s", out)
require.NoError(c.t, err)
return string(out)
return deep
}
type OrgUsers struct {
Admin User
Editor User
Viewer User
// The team with admin+editor in it (but not viewer)
Staff team.Team
}
type User struct {
@ -243,7 +282,7 @@ func (c *K8sTestHelper) PostResource(user User, resource string, payload AnyReso
namespace := payload.Namespace
if namespace == "" {
namespace = c.namespacer(user.Identity.GetOrgID())
namespace = c.Namespacer(user.Identity.GetOrgID())
}
path := fmt.Sprintf("/apis/%s/namespaces/%s/%s",
@ -383,11 +422,14 @@ func (c *K8sTestHelper) LoadYAMLOrJSON(body string) *unstructured.Unstructured {
func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers {
c.t.Helper()
return OrgUsers{
users := OrgUsers{
Admin: c.CreateUser("admin", orgName, org.RoleAdmin, nil),
Editor: c.CreateUser("editor", orgName, org.RoleEditor, nil),
Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil),
}
users.Staff = c.CreateTeam("staff", "staff@"+orgName, users.Admin.Identity.GetOrgID())
// TODO add admin and editor to staff
return users
}
func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.RoleType, permissions []resourcepermissions.SetResourcePermissionCommand) User {
@ -542,3 +584,11 @@ func (c *K8sTestHelper) CreateDS(cmd *datasources.AddDataSourceCommand) *datasou
require.NoError(c.t, err)
return dataSource
}
func (c *K8sTestHelper) CreateTeam(name, email string, orgID int64) team.Team {
c.t.Helper()
team, err := c.env.Server.HTTPServer.TeamService.CreateTeam(context.Background(), name, email, orgID)
require.NoError(c.t, err)
return team
}

@ -0,0 +1,150 @@
package identity
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var gvrTeams = schema.GroupVersionResource{
Group: "identity.grafana.app",
Version: "v0alpha1",
Resource: "teams",
}
var gvrUsers = schema.GroupVersionResource{
Group: "identity.grafana.app",
Version: "v0alpha1",
Resource: "users",
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestIntegrationRequiresDevMode(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true, // should fail
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
},
})
_, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("identity.grafana.app/v0alpha1")
require.Error(t, err)
}
func TestIntegrationIdentity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for experimental APIs
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
},
})
_, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("identity.grafana.app/v0alpha1")
require.NoError(t, err)
t.Run("read only views", func(t *testing.T) {
ctx := context.Background()
teamClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrTeams,
})
rsp, err := teamClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
found := teamClient.SanitizeJSONList(rsp, "name")
// fmt.Printf("%s", found)
require.JSONEq(t, `{
"items": [
{
"apiVersion": "identity.grafana.app/v0alpha1",
"kind": "Team",
"metadata": {
"annotations": {
"grafana.app/originName": "SQL",
"grafana.app/originPath": "${originPath}"
},
"creationTimestamp": "${creationTimestamp}",
"name": "${name}",
"namespace": "default",
"resourceVersion": "${resourceVersion}"
},
"spec": {
"email": "staff@Org1",
"name": "staff"
}
}
]
}`, found)
// Org1 users
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrUsers,
})
rsp, err = userClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
// Get just the specs (avoids values that change with each deployment)
found = teamClient.SpecJSON(rsp)
// fmt.Printf("%s", found) // NOTE the first value does not have an email or login
require.JSONEq(t, `[
{},
{
"email": "admin-1",
"login": "admin-1"
},
{
"email": "editor-1",
"login": "editor-1"
},
{
"email": "viewer-1",
"login": "viewer-1"
}
]`, found)
// OrgB users
userClient = helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin, // super admin
Namespace: helper.Namespacer(helper.OrgB.Admin.Identity.GetOrgID()), // list values for orgB with super admin user
GVR: gvrUsers,
})
rsp, err = userClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
// Get just the specs (avoids values that change with each deployment)
found = teamClient.SpecJSON(rsp)
fmt.Printf("%s", found) // NOTE the first value does not have an email or login
require.JSONEq(t, `[
{
"email": "admin-3",
"login": "admin-3"
},
{
"email": "editor-3",
"login": "editor-3"
},
{
"email": "viewer-3",
"login": "viewer-3"
}
]`, found)
})
}
Loading…
Cancel
Save