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

This commit is contained in:
Ryan McKinley 2024-08-15 14:38:43 +03:00 committed by GitHub
parent c7fdf8ce70
commit a0cd89860e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1535 additions and 282 deletions

View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

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

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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"},
}
}

View File

@ -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

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
})
}
})
}
})
}
}

View File

@ -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

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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 ?

View File

@ -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 ?

View File

@ -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 ?

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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 ?

View File

@ -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 ?

View File

@ -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 ?

View File

@ -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)
}

View File

@ -5,16 +5,16 @@ import (
"fmt"
"strconv"
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"
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/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/user"
)
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
}

View File

@ -4,16 +4,17 @@ import (
"context"
"strconv"
"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"
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/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/team"
)
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
}

View File

@ -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
}

View File

@ -5,16 +5,16 @@ import (
"fmt"
"strconv"
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"
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/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/user"
)
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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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(

View File

@ -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"`

View File

@ -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)

View File

@ -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 {

View File

@ -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),

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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),

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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.

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
})
}