Stars: Move stars from preferences apiserver to a new collections apiserver (#114006)

This commit is contained in:
Ryan McKinley
2025-11-19 08:28:39 +03:00
committed by GitHub
parent e558c9af5d
commit 00329cab14
96 changed files with 3416 additions and 2380 deletions
@@ -1,35 +0,0 @@
package preferences
import (
"context"
"fmt"
"k8s.io/apiserver/pkg/admission"
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
)
func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
switch a.GetOperation() {
case admission.Create, admission.Update:
// ignore anything that is not CREATE | UPDATE
default:
return nil
}
obj := a.GetObject()
if obj == nil {
return nil
}
switch a.GetResource().Resource {
case "stars":
stars, ok := obj.(*preferences.Stars)
if !ok {
return fmt.Errorf("expected stars object: (%T)", obj)
}
stars.Spec.Normalize()
return nil
}
return nil
}
@@ -26,52 +26,11 @@ func mustTemplate(filename string) *template.Template {
// Templates.
var (
sqlDashboardStarsQuery = mustTemplate("sql_dashboard_stars.sql")
sqlDashboardStarsRV = mustTemplate("sql_dashboard_stars_rv.sql")
sqlHistoryStarsQuery = mustTemplate("sql_history_stars.sql")
sqlHistoryStarsInsert = mustTemplate("sql_history_stars_insert.sql")
sqlHistoryStarsDelete = mustTemplate("sql_history_stars_delete.sql")
sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql")
sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql")
sqlTeams = mustTemplate("sql_teams.sql")
sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql")
sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql")
sqlTeams = mustTemplate("sql_teams.sql")
)
type starQuery struct {
sqltemplate.SQLTemplate
OrgID int64 // >= 1 if UserID != ""
UserUID string
UserID int64 // for stars
QueryUIDs []string
QueryUID string
StarTable string
UserTable string
QueryHistoryStarsTable string
QueryHistoryTable string
}
func (r starQuery) Validate() error {
if r.UserUID != "" && r.OrgID < 1 {
return fmt.Errorf("requests with a userid, must include an orgID")
}
return nil
}
func newStarQueryReq(sql *legacysql.LegacyDatabaseHelper, user string, orgId int64) starQuery {
return starQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserUID: user,
OrgID: orgId,
StarTable: sql.Table("star"),
UserTable: sql.Table("user"),
QueryHistoryStarsTable: sql.Table("query_history_star"),
QueryHistoryTable: sql.Table("query_history"),
}
}
type preferencesQuery struct {
sqltemplate.SQLTemplate
@@ -17,21 +17,6 @@ func TestStarsQueries(t *testing.T) {
},
}
getStarQuery := func(orgId int64, user string) sqltemplate.SQLTemplate {
v := newStarQueryReq(nodb, user, orgId)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
getHistoryReq := func(orgId int64, userId int64, stars []string, star string) sqltemplate.SQLTemplate {
v := newStarQueryReq(nodb, "", orgId)
v.UserID = userId
v.QueryUIDs = stars
v.QueryUID = star
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
getPreferencesQuery := func(orgId int64, cb func(q *preferencesQuery)) sqltemplate.SQLTemplate {
v := newPreferencesQueryReq(nodb, orgId)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
@@ -49,56 +34,6 @@ func TestStarsQueries(t *testing.T) {
RootDir: "testdata",
SQLTemplatesFS: sqlTemplatesFS,
Templates: map[*template.Template][]mocks.TemplateTestCase{
sqlDashboardStarsQuery: {
{
Name: "all",
Data: getStarQuery(0, ""),
},
{
Name: "org",
Data: getStarQuery(3, ""),
},
{
Name: "user",
Data: getStarQuery(3, "abc"),
},
},
sqlDashboardStarsRV: {
{
Name: "get",
Data: getStarQuery(0, ""),
},
},
sqlHistoryStarsQuery: {
{
Name: "user",
Data: getStarQuery(1, "abc"),
},
},
sqlHistoryStarsQuery: {
{
Name: "org",
Data: getStarQuery(1, ""),
},
},
sqlHistoryStarsInsert: {
{
Name: "add star",
Data: getHistoryReq(1, 3, nil, "XXX"),
},
},
sqlHistoryStarsDelete: {
{
Name: "remove star",
Data: getHistoryReq(1, 3, []string{"xxx", "yyy"}, ""),
},
},
sqlHistoryStarsDelete: {
{
Name: "remove all star",
Data: getHistoryReq(1, 3, nil, ""),
},
},
sqlPreferencesQuery: {
{
Name: "all",
-181
View File
@@ -12,20 +12,10 @@ import (
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
type dashboardStars struct {
OrgID int64
UserUID string
First int64
Last int64
Dashboards []string
}
type preferenceModel struct {
ID int64
OrgID int64
@@ -49,177 +39,6 @@ func NewLegacySQL(db legacysql.LegacyDatabaseProvider) *LegacySQL {
return &LegacySQL{db: db, startup: time.Now()}
}
// NOTE: this does not support paging -- lets check if that will be a problem in cloud
func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user string) ([]dashboardStars, int64, error) {
var max sql.NullString
sql, err := s.db(ctx)
if err != nil {
return nil, 0, err
}
req := newStarQueryReq(sql, user, orgId)
q, err := sqltemplate.Execute(sqlDashboardStarsQuery, req)
if err != nil {
return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsQuery.Name(), err)
}
sess := sql.DB.GetSqlxSession()
rows, err := sess.Query(ctx, q, req.GetArgs()...)
if err != nil {
return nil, 0, err
}
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
stars := []dashboardStars{}
current := &dashboardStars{}
var orgID int64
var userUID string
var dashboardUID string
var updated time.Time
for rows.Next() {
err := rows.Scan(&orgID, &userUID, &dashboardUID, &updated)
if err != nil {
return nil, 0, err
}
if orgID != current.OrgID || userUID != current.UserUID {
if current.UserUID != "" {
stars = append(stars, *current)
}
current = &dashboardStars{
OrgID: orgID,
UserUID: userUID,
}
}
ts := updated.UnixMilli()
if ts > current.Last {
current.Last = ts
}
if ts < current.First || current.First == 0 {
current.First = ts
}
current.Dashboards = append(current.Dashboards, dashboardUID)
}
// Add the last value
if current.UserUID != "" {
stars = append(stars, *current)
}
// Find the RV unless it is a user query
if userUID == "" {
req.Reset()
q, err = sqltemplate.Execute(sqlDashboardStarsRV, req)
if err != nil {
return nil, 0, fmt.Errorf("execute template %q: %w", sqlPreferencesRV.Name(), err)
}
err = sess.Get(ctx, &max, q)
if err != nil {
return nil, 0, fmt.Errorf("unable to get RV %w", err)
}
if max.Valid && max.String != "" {
t, _ := time.Parse(time.RFC3339, max.String)
if !t.IsZero() {
updated = t
}
} else {
updated = s.startup
}
}
return stars, updated.UnixMilli(), err
}
func (s *LegacySQL) getHistoryStars(ctx context.Context, orgId int64, user string) (map[string][]string, error) {
sql, err := s.db(ctx)
if err != nil {
return nil, err
}
req := newStarQueryReq(sql, user, orgId)
q, err := sqltemplate.Execute(sqlHistoryStarsQuery, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlHistoryStarsQuery.Name(), err)
}
sess := sql.DB.GetSqlxSession()
rows, err := sess.Query(ctx, q, req.GetArgs()...)
if err != nil {
return nil, err
}
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
last := user
res := make(map[string][]string)
buffer := make([]string, 0, 10)
var uid string
for rows.Next() {
err := rows.Scan(&uid, &user)
if err != nil {
return nil, err
}
if user != last && len(buffer) > 0 {
res[last] = buffer
buffer = make([]string, 0, 10)
}
buffer = append(buffer, uid)
last = user
}
res[last] = buffer
return res, nil
}
func (s *LegacySQL) removeHistoryStar(ctx context.Context, user *user.User, stars []string) error {
sql, err := s.db(ctx)
if err != nil {
return err
}
req := newStarQueryReq(sql, "", user.OrgID)
req.UserID = user.ID
if len(stars) > 0 {
req.QueryUIDs = stars
}
q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req)
if err != nil {
return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err)
}
sess := sql.DB.GetSqlxSession()
_, err = sess.Exec(ctx, q, req.GetArgs()...)
return err
}
func (s *LegacySQL) addHistoryStar(ctx context.Context, user *user.User, star string) error {
sql, err := s.db(ctx)
if err != nil {
return err
}
req := newStarQueryReq(sql, "", user.OrgID)
req.UserID = user.ID
req.QueryUID = star
q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req)
if err != nil {
return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err)
}
sess := sql.DB.GetSqlxSession()
_, err = sess.Exec(ctx, q, req.GetArgs()...)
return err
}
// List all defined preferences in an org (valid for admin users only)
func (s *LegacySQL) listPreferences(ctx context.Context,
ns string, orgId int64,
@@ -1,10 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM {{ .Ident .StarTable }} as s
JOIN {{ .Ident .UserTable }} as u ON s.user_id = u.id
{{ if ge .OrgID 1 }}
WHERE s.org_id = {{ .Arg .OrgID }}
{{ if .UserUID }}
AND u.uid = {{ .Arg .UserUID }}
{{ end }}{{ end }}
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1 +0,0 @@
SELECT MAX(updated) FROM {{ .Ident .StarTable }}
@@ -1,9 +0,0 @@
SELECT s.query_uid, u.uid as user_uid
FROM {{ .Ident .QueryHistoryStarsTable }} as s
JOIN {{ .Ident .QueryHistoryTable }} as h ON s.query_uid = h.uid
JOIN {{ .Ident .UserTable }} as u ON s.user_id = u.id
WHERE s.org_id = {{ .Arg .OrgID }}
{{ if .UserUID }}
AND u.uid = {{ .Arg .UserUID }}
{{ end }}
ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc
@@ -1,6 +0,0 @@
DELETE FROM {{ .Ident .QueryHistoryStarsTable }}
WHERE org_id = {{ .Arg .OrgID }}
AND user_id = {{ .Arg .UserID }}
{{ if .QueryUIDs }}
AND query_uid IN ({{ .ArgList .QueryUIDs }})
{{ end }}
@@ -1,4 +0,0 @@
INSERT INTO {{ .Ident .QueryHistoryStarsTable }}
( query_uid, user_id, org_id )
VALUES
( {{ .Arg .QueryUID }}, {{ .Arg .UserID }}, {{ .Arg .OrgID }} )
@@ -1,349 +0,0 @@
package legacy
import (
"context"
"fmt"
"math/rand"
"slices"
"strconv"
"strings"
"time"
apiserrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/utils/ptr"
authlib "github.com/grafana/authlib/types"
dashboardsV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
)
var (
_ rest.Scoper = (*DashboardStarsStorage)(nil)
_ rest.SingularNameProvider = (*DashboardStarsStorage)(nil)
_ rest.Getter = (*DashboardStarsStorage)(nil)
_ rest.Lister = (*DashboardStarsStorage)(nil)
_ rest.Storage = (*DashboardStarsStorage)(nil)
_ rest.Creater = (*DashboardStarsStorage)(nil)
_ rest.Updater = (*DashboardStarsStorage)(nil)
_ rest.GracefulDeleter = (*DashboardStarsStorage)(nil)
_ rest.CollectionDeleter = (*DashboardStarsStorage)(nil)
)
func NewDashboardStarsStorage(
stars star.Service,
users user.Service,
namespacer request.NamespaceMapper,
sql *LegacySQL,
) *DashboardStarsStorage {
return &DashboardStarsStorage{
stars: stars,
users: users,
namespacer: namespacer,
sql: sql,
tableConverter: preferences.StarsResourceInfo.TableConverter(),
}
}
type DashboardStarsStorage struct {
namespacer request.NamespaceMapper
tableConverter rest.TableConvertor
sql *LegacySQL
stars star.Service
users user.Service
}
func (s *DashboardStarsStorage) New() runtime.Object {
return preferences.StarsKind().ZeroValue()
}
func (s *DashboardStarsStorage) Destroy() {}
func (s *DashboardStarsStorage) NamespaceScoped() bool {
return true // namespace == org
}
func (s *DashboardStarsStorage) GetSingularName() string {
return strings.ToLower(preferences.StarsKind().Kind())
}
func (s *DashboardStarsStorage) NewList() runtime.Object {
return preferences.StarsKind().ZeroListValue()
}
func (s *DashboardStarsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
ns, err := request.NamespaceInfoFrom(ctx, false)
if err != nil {
return nil, err
}
if ns.Value == "" {
return nil, fmt.Errorf("cross cluster listing is not supported")
}
userInfo, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
user := userInfo.GetIdentifier()
if userInfo.GetIdentityType() == authlib.TypeAccessPolicy {
user = "" // can see everything
}
list := &preferences.StarsList{}
found, rv, err := s.sql.getDashboardStars(ctx, ns.OrgID, user)
if err != nil {
return nil, err
}
history, err := s.sql.getHistoryStars(ctx, ns.OrgID, "")
if err != nil {
return nil, err
}
for _, v := range found {
list.Items = append(list.Items,
asStarsResource(s.namespacer(v.OrgID), &v, history[v.UserUID]))
}
if rv > 0 {
list.ResourceVersion = strconv.FormatInt(rv, 10)
}
return list, nil
}
func getNamespaceAndOwner(ctx context.Context, name string) (authlib.NamespaceInfo, utils.OwnerReference, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return info, utils.OwnerReference{}, err
}
owner, ok := utils.ParseOwnerFromName(name)
if !ok {
return info, owner, fmt.Errorf("invalid name %w", err)
}
if owner.Owner != utils.UserResourceOwner {
return info, owner, fmt.Errorf("expecting name with prefix: %s-", utils.UserResourceOwner)
}
return info, owner, nil
}
func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
ns, owner, err := getNamespaceAndOwner(ctx, name)
if err != nil {
return nil, err
}
found, _, err := s.sql.getDashboardStars(ctx, ns.OrgID, owner.Identifier)
if err != nil {
return nil, err
}
history, err := s.sql.getHistoryStars(ctx, ns.OrgID, owner.Identifier)
if err != nil {
return nil, err
}
if len(found) == 0 || len(found[0].Dashboards) == 0 {
return nil, apiserrors.NewNotFound(preferences.StarsResourceInfo.GroupResource(), name)
}
obj := asStarsResource(ns.Value, &found[0], history[owner.Identifier])
return &obj, nil
}
func getStars(stars *preferences.Stars, gk schema.GroupKind) []string {
if stars == nil || len(stars.Spec.Resource) == 0 {
return []string{}
}
for _, r := range stars.Spec.Resource {
if r.Group == gk.Group && r.Kind == gk.Kind {
return r.Names
}
}
return []string{}
}
// Create implements rest.Creater.
func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars) (runtime.Object, error) {
ns, owner, err := getNamespaceAndOwner(ctx, obj.Name)
if err != nil {
return nil, err
}
user, err := s.users.GetByUID(ctx, &user.GetUserByUIDQuery{
UID: owner.Identifier,
})
if err != nil {
return nil, err
}
if user.OrgID != ns.OrgID {
return nil, fmt.Errorf("namespace mismatch")
}
stars := getStars(obj, schema.GroupKind{Group: "dashboard.grafana.app", Kind: "Dashboard"})
if len(stars) == 0 {
err = s.stars.DeleteByUser(ctx, user.ID)
return &preferences.Stars{ObjectMeta: metav1.ObjectMeta{
Name: obj.Name,
Namespace: obj.Namespace,
DeletionTimestamp: ptr.To(metav1.Now()),
}}, err
}
current, _, err := s.sql.getDashboardStars(ctx, ns.OrgID, owner.Identifier)
if err != nil {
return nil, err
}
changed := false
now := time.Now()
randID := now.UnixNano() + rand.Int63n(5000)
previous := make(map[string]bool)
if len(current) > 0 {
for _, v := range current[0].Dashboards {
previous[v] = true
}
}
for _, dashboard := range stars {
if previous[dashboard] {
delete(previous, dashboard)
continue // nothing needed
}
err = s.stars.Add(ctx, &star.StarDashboardCommand{
UserID: user.ID,
OrgID: user.OrgID,
DashboardUID: dashboard,
DashboardID: randID,
Updated: now,
})
if err != nil {
return nil, err
}
changed = true
randID++
}
for k := range previous {
err = s.stars.Delete(ctx, &star.UnstarDashboardCommand{
UserID: user.ID,
OrgID: user.OrgID,
DashboardUID: k,
})
if err != nil {
return nil, err
}
changed = true
}
// Apply history stars
stars = getStars(obj, schema.GroupKind{Group: "history.grafana.app", Kind: "Query"})
res, err := s.sql.getHistoryStars(ctx, user.OrgID, user.UID)
if err != nil {
return nil, err
}
history := res[user.UID]
if !slices.Equal(stars, history) {
changed = true
if len(stars) == 0 {
err = s.sql.removeHistoryStar(ctx, user, nil)
if err != nil {
return nil, err
}
} else {
added, removed, _ := preferences.Changes(history, stars)
if len(removed) > 0 {
_ = s.sql.removeHistoryStar(ctx, user, nil)
}
for _, v := range added {
_ = s.sql.addHistoryStar(ctx, user, v) // one at a time so duplicates do not fail everything
}
}
}
if changed {
return s.Get(ctx, obj.Name, &metav1.GetOptions{})
}
return obj, nil // nothing required
}
// Create implements rest.Creater.
func (s *DashboardStarsStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
stars, ok := obj.(*preferences.Stars)
if !ok {
return nil, fmt.Errorf("expected stars object")
}
return s.write(ctx, stars)
}
// Update implements rest.Updater.
func (s *DashboardStarsStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
old, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
obj, err := objInfo.UpdatedObject(ctx, old)
if err != nil {
return nil, false, err
}
stars, ok := obj.(*preferences.Stars)
if !ok {
return nil, false, fmt.Errorf("expected stars object")
}
obj, err = s.write(ctx, stars)
return obj, false, err
}
// Delete implements rest.GracefulDeleter.
func (s *DashboardStarsStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
obj, err := s.write(ctx, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}})
if err != nil {
return nil, false, err
}
return obj, true, err
}
// DeleteCollection implements rest.CollectionDeleter.
func (s *DashboardStarsStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
return nil, fmt.Errorf("not implemented yet")
}
func asStarsResource(ns string, v *dashboardStars, history []string) preferences.Stars {
stars := preferences.Stars{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("user-%s", v.UserUID),
Namespace: ns,
ResourceVersion: strconv.FormatInt(v.Last, 10),
CreationTimestamp: metav1.NewTime(time.UnixMilli(v.First)),
},
Spec: preferences.StarsSpec{
Resource: []preferences.StarsResource{{
Group: dashboardsV1.APIGroup,
Kind: "Dashboard",
Names: v.Dashboards,
}},
},
}
if len(history) > 0 {
stars.Spec.Resource = append(stars.Spec.Resource, preferences.StarsResource{
Group: "history.grafana.app",
Kind: "Query",
Names: history,
})
}
stars.Spec.Normalize()
return stars
}
@@ -1,5 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM `grafana`.`star` as s
JOIN `grafana`.`user` as u ON s.user_id = u.id
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1,6 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM `grafana`.`star` as s
JOIN `grafana`.`user` as u ON s.user_id = u.id
WHERE s.org_id = 3
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1,7 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM `grafana`.`star` as s
JOIN `grafana`.`user` as u ON s.user_id = u.id
WHERE s.org_id = 3
AND u.uid = 'abc'
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1 +0,0 @@
SELECT MAX(updated) FROM `grafana`.`star`
@@ -1,6 +0,0 @@
SELECT s.query_uid, u.uid as user_uid
FROM `grafana`.`query_history_star` as s
JOIN `grafana`.`query_history` as h ON s.query_uid = h.uid
JOIN `grafana`.`user` as u ON s.user_id = u.id
WHERE s.org_id = 1
ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc
@@ -1,3 +0,0 @@
DELETE FROM `grafana`.`query_history_star`
WHERE org_id = 1
AND user_id = 3
@@ -1,4 +0,0 @@
INSERT INTO `grafana`.`query_history_star`
( query_uid, user_id, org_id )
VALUES
( 'XXX', 3, 1 )
@@ -1,5 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM "grafana"."star" as s
JOIN "grafana"."user" as u ON s.user_id = u.id
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1,6 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM "grafana"."star" as s
JOIN "grafana"."user" as u ON s.user_id = u.id
WHERE s.org_id = 3
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1,7 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM "grafana"."star" as s
JOIN "grafana"."user" as u ON s.user_id = u.id
WHERE s.org_id = 3
AND u.uid = 'abc'
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1 +0,0 @@
SELECT MAX(updated) FROM "grafana"."star"
@@ -1,6 +0,0 @@
SELECT s.query_uid, u.uid as user_uid
FROM "grafana"."query_history_star" as s
JOIN "grafana"."query_history" as h ON s.query_uid = h.uid
JOIN "grafana"."user" as u ON s.user_id = u.id
WHERE s.org_id = 1
ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc
@@ -1,3 +0,0 @@
DELETE FROM "grafana"."query_history_star"
WHERE org_id = 1
AND user_id = 3
@@ -1,4 +0,0 @@
INSERT INTO "grafana"."query_history_star"
( query_uid, user_id, org_id )
VALUES
( 'XXX', 3, 1 )
@@ -1,5 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM "grafana"."star" as s
JOIN "grafana"."user" as u ON s.user_id = u.id
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1,6 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM "grafana"."star" as s
JOIN "grafana"."user" as u ON s.user_id = u.id
WHERE s.org_id = 3
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1,7 +0,0 @@
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
FROM "grafana"."star" as s
JOIN "grafana"."user" as u ON s.user_id = u.id
WHERE s.org_id = 3
AND u.uid = 'abc'
ORDER BY
s.org_id asc, s.user_id asc, s.updated asc
@@ -1 +0,0 @@
SELECT MAX(updated) FROM "grafana"."star"
@@ -1,6 +0,0 @@
SELECT s.query_uid, u.uid as user_uid
FROM "grafana"."query_history_star" as s
JOIN "grafana"."query_history" as h ON s.query_uid = h.uid
JOIN "grafana"."user" as u ON s.user_id = u.id
WHERE s.org_id = 1
ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc
@@ -1,3 +0,0 @@
DELETE FROM "grafana"."query_history_star"
WHERE org_id = 1
AND user_id = 3
@@ -1,4 +0,0 @@
INSERT INTO "grafana"."query_history_star"
( query_uid, user_id, org_id )
VALUES
( 'XXX', 3, 1 )
+5 -92
View File
@@ -1,8 +1,6 @@
package preferences
import (
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -10,12 +8,9 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/preferences/legacy"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
@@ -23,20 +18,17 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql"
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
)
type APIBuilder struct {
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
legacyPrefs rest.Storage
merger *merger // joins all preferences
@@ -47,7 +39,6 @@ func RegisterAPIService(
features featuremgmt.FeatureToggles,
db db.DB,
prefs pref.Service,
stars star.Service,
users user.Service,
apiregistration builder.APIRegistrar,
) *APIBuilder {
@@ -60,11 +51,10 @@ func RegisterAPIService(
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
builder := &APIBuilder{
merger: newMerger(cfg, sql),
authorizer: &authorizeFromName{
oknames: []string{"merged"},
teams: sql, // should be from the IAM service
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
authorizer: &utils.AuthorizeFromName{
OKNames: []string{"merged"},
Teams: sql, // should be from the IAM service
Resource: map[string][]utils.ResourceOwner{
"preferences": {
utils.NamespaceResourceOwner,
utils.TeamResourceOwner,
@@ -78,10 +68,6 @@ func RegisterAPIService(
if prefs != nil {
builder.legacyPrefs = legacy.NewPreferencesStorage(prefs, namespacer, sql)
}
if stars != nil {
builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql)
}
apiregistration.RegisterAPI(builder)
return builder
}
@@ -109,24 +95,6 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storage := map[string]rest.Storage{}
// Configure Stars Dual writer
resource := preferences.StarsResourceInfo
var stars grafanarest.Storage
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
if err != nil {
return err
}
stars = &starStorage{Storage: stars} // wrap List so we only return one value
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
if err != nil {
return err
}
}
storage[resource.StoragePath()] = stars
storage[resource.StoragePath("update")] = &starsREST{store: stars}
// Configure Preferences
prefs := preferences.PreferencesResourceInfo
storage[prefs.StoragePath()] = b.legacyPrefs
@@ -146,58 +114,3 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.merger.GetAPIRoutes(defs)
}
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
oas.Info.Description = "Grafana preferences"
root := "/apis/" + b.GetGroupVersion().String() + "/"
updateKey := root + "namespaces/{namespace}/stars/{name}/update"
delete(oas.Paths.Paths, updateKey)
// Add the group/kind/id properties to the path
stars, ok := oas.Paths.Paths[updateKey+"/{path}"]
if !ok || stars == nil {
return nil, fmt.Errorf("unable to find write path")
}
stars.Parameters = []*spec3.Parameter{
stars.Parameters[0], // name
stars.Parameters[1], // namespace
{
ParameterProps: spec3.ParameterProps{
Name: "group",
In: "path",
Example: "dashboard.grafana.app",
Description: "API group for stared item",
Schema: spec.StringProperty(),
Required: true,
},
}, {
ParameterProps: spec3.ParameterProps{
Name: "kind",
In: "path",
Example: "Dashboard",
Description: "Kind for stared item",
Schema: spec.StringProperty(),
Required: true,
},
}, {
ParameterProps: spec3.ParameterProps{
Name: "id",
In: "path",
Example: "",
Description: "The k8s name for the selected item",
Schema: spec.StringProperty(),
Required: true,
},
},
}
stars.Put.Description = "Add a starred item"
stars.Put.OperationId = "addStar"
stars.Delete.Description = "Remove a starred item"
stars.Delete.OperationId = "removeStar"
delete(oas.Paths.Paths, updateKey+"/{path}")
oas.Paths.Paths[updateKey+"/{group}/{kind}/{id}"] = stars
return oas, nil
}
-48
View File
@@ -1,48 +0,0 @@
package preferences
import (
"context"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
authlib "github.com/grafana/authlib/types"
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
)
var _ grafanarest.Storage = (*starStorage)(nil)
type starStorage struct {
grafanarest.Storage
}
// When using list, we really just want to get the value for the single user
func (s *starStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
switch user.GetIdentityType() {
case authlib.TypeAnonymous:
return s.NewList(), nil
// Get the single user stars
case authlib.TypeUser:
stars := &preferences.StarsList{}
obj, _ := s.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{})
if obj != nil {
s, ok := obj.(*preferences.Stars)
if ok {
stars.Items = []preferences.Stars{*s}
}
}
return stars, nil
default:
return s.Storage.List(ctx, options)
}
}
@@ -1,149 +0,0 @@
package preferences
import (
"context"
"fmt"
"net/http"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
)
type starItem struct {
group string
kind string
id string
}
type starsREST struct {
store grafanarest.Storage
}
var (
_ = rest.Connecter(&starsREST{})
_ = rest.StorageMetadata(&starsREST{})
)
func (r *starsREST) New() runtime.Object {
return &preferences.Stars{}
}
func (r *starsREST) Destroy() {}
func (r *starsREST) ConnectMethods() []string {
return []string{"PUT", "DELETE"}
}
func (r *starsREST) ProducesMIMETypes(verb string) []string {
return nil
}
func (r *starsREST) ProducesObject(verb string) interface{} {
return &preferences.Stars{}
}
func (r *starsREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, true, "" // true means you can use the trailing path as a variable
}
func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, fmt.Errorf("must be logged in")
}
parsed, found := utils.ParseOwnerFromName(name)
if !found || parsed.Owner != utils.UserResourceOwner {
return nil, fmt.Errorf("only works with user stars")
}
if user.GetIdentifier() != parsed.Identifier {
return nil, fmt.Errorf("must request as the given user")
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
item, err := itemFromPath(req.URL.Path, fmt.Sprintf("/%s/update", name))
if err != nil {
responder.Error(err)
return
}
remove := false
switch req.Method {
case "DELETE":
remove = true
case "PUT":
remove = false
default:
responder.Error(apierrors.NewMethodNotSupported(preferences.PreferencesResourceInfo.GroupResource(), req.Method))
return
}
current, err := r.store.Get(ctx, name, &v1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
if remove {
responder.Object(http.StatusNoContent, &v1.Status{
Code: http.StatusNoContent,
})
return
}
current = &preferences.Stars{
ObjectMeta: v1.ObjectMeta{
Name: name,
Namespace: user.GetNamespace(),
},
}
}
}
obj, ok := current.(*preferences.Stars)
if !ok {
responder.Error(fmt.Errorf("expected stars object"))
return
}
if remove {
obj.Spec.Remove(item.group, item.kind, item.id)
} else {
obj.Spec.Add(item.group, item.kind, item.id)
}
if len(obj.Spec.Resource) == 0 {
_, _, err = r.store.Delete(ctx, name, rest.ValidateAllObjectFunc, &v1.DeleteOptions{})
} else if obj.ResourceVersion == "" {
_, err = r.store.Create(ctx, obj, rest.ValidateAllObjectFunc, &v1.CreateOptions{})
} else {
_, _, err = r.store.Update(ctx, name, rest.DefaultUpdatedObjectInfo(obj), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, true, &v1.UpdateOptions{})
}
if err != nil {
responder.Error(err)
return
}
responder.Object(http.StatusOK, &v1.Status{Code: http.StatusOK})
}), nil
}
func itemFromPath(urlPath, prefix string) (starItem, error) {
idx := strings.Index(urlPath, prefix)
if idx == -1 {
return starItem{}, apierrors.NewBadRequest("invalid request path")
}
path := strings.TrimPrefix(urlPath[idx+len(prefix):], "/")
parts := strings.Split(path, "/")
if len(parts) != 3 {
return starItem{}, apierrors.NewBadRequest("expected {group}/{kind}/{id}")
}
return starItem{
group: parts[0],
kind: parts[1],
id: parts[2],
}, nil
}
@@ -1,49 +0,0 @@
package preferences
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestStarsWrite(t *testing.T) {
t.Run("path", func(t *testing.T) {
tests := []struct {
name string
url string
prefix string
item starItem
err string
}{{
name: "normal",
url: "http://localhost:3000/apis/preferences.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127",
prefix: "/user-abc/write",
item: starItem{
group: "dashboard.grafana.app",
kind: "Dashboard",
id: "000000127",
},
}, {
name: "prefix not found",
url: "http://localhost:3000/apis/preferences.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127",
prefix: "/something/write",
err: "invalid request path",
}, {
name: "missing three parts",
url: "http://localhost:3000/apis/preferences.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/000000127",
prefix: "/user-abc/write",
err: "expected {group}/{kind}/{id}",
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
item, err := itemFromPath(tt.url, tt.prefix)
if tt.err == "" {
require.NoError(t, err)
require.Equal(t, tt.item, item)
} else {
require.ErrorContains(t, err, tt.err)
}
})
}
})
}
@@ -1,4 +1,4 @@
package preferences
package utils
import (
"context"
@@ -9,16 +9,15 @@ import (
"github.com/grafana/authlib/authz"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
)
type authorizeFromName struct {
teams utils.TeamService
oknames []string
resource map[string][]utils.ResourceOwner // may include unknown
type AuthorizeFromName struct {
Teams TeamService
OKNames []string
Resource map[string][]ResourceOwner // may include unknown
}
func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
func (a *AuthorizeFromName) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
user, err := identity.GetRequester(ctx)
if err != nil || user == nil {
return authorizer.DecisionDeny, "valid user is required", err
@@ -28,7 +27,7 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri
return authorizer.DecisionNoOpinion, "", nil
}
owners, ok := a.resource[attr.GetResource()]
owners, ok := a.Resource[attr.GetResource()]
if !ok {
return authorizer.DecisionDeny, "missing resource name", nil
}
@@ -55,17 +54,17 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri
}
// the pseudo sub-resource
if a.oknames != nil && slices.Contains(a.oknames, attr.GetName()) {
if a.OKNames != nil && slices.Contains(a.OKNames, attr.GetName()) {
return authorizer.DecisionAllow, "", nil
}
info, _ := utils.ParseOwnerFromName(attr.GetName())
info, _ := ParseOwnerFromName(attr.GetName())
if !slices.Contains(owners, info.Owner) {
return authorizer.DecisionDeny, "unsupported owner type", nil
}
switch info.Owner {
case utils.NamespaceResourceOwner:
case NamespaceResourceOwner:
if attr.IsReadOnly() {
// Everyone can see the namespace
return authorizer.DecisionAllow, "", nil
@@ -75,17 +74,17 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri
}
return authorizer.DecisionDeny, "must be an org admin to edit", nil
case utils.UserResourceOwner:
case UserResourceOwner:
if user.GetIdentifier() == info.Identifier {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "your are not the owner of the resource", nil
case utils.TeamResourceOwner:
if a.teams == nil {
case TeamResourceOwner:
if a.Teams == nil {
return authorizer.DecisionDeny, "team checker not configured", err
}
ok, err := a.teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly())
ok, err := a.Teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly())
if err != nil {
return authorizer.DecisionDeny, "error fetching teams", err
}
@@ -94,7 +93,7 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri
}
return authorizer.DecisionDeny, "you are not a member of the referenced team", nil
case utils.UnknownResourceOwner:
case UnknownResourceOwner:
return authorizer.DecisionAllow, "", nil
}
@@ -1,4 +1,4 @@
package preferences
package utils
import (
"context"
@@ -11,7 +11,6 @@ import (
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
)
type expect struct {
@@ -41,14 +40,14 @@ func TestAuthorizer_Authorize(t *testing.T) {
tests := []struct {
name string
teams func(t *testing.T) utils.TeamService
resource map[string][]utils.ResourceOwner
teams func(t *testing.T) TeamService
resource map[string][]ResourceOwner
check []testCase
}{
{
name: "stars",
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
resource: map[string][]ResourceOwner{
"stars": {UserResourceOwner},
},
check: []testCase{{
name: "matches user",
@@ -80,9 +79,9 @@ func TestAuthorizer_Authorize(t *testing.T) {
}},
}, {
name: "fast path",
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
"preferences": {utils.TeamResourceOwner},
resource: map[string][]ResourceOwner{
"stars": {UserResourceOwner},
"preferences": {TeamResourceOwner},
},
check: []testCase{{
name: "missing user",
@@ -185,8 +184,8 @@ func TestAuthorizer_Authorize(t *testing.T) {
}},
}, {
name: "unknown owner",
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UnknownResourceOwner},
resource: map[string][]ResourceOwner{
"stars": {UnknownResourceOwner},
},
check: []testCase{{
name: "get",
@@ -204,8 +203,8 @@ func TestAuthorizer_Authorize(t *testing.T) {
}},
}, {
name: "namespace",
resource: map[string][]utils.ResourceOwner{
"ns": {utils.NamespaceResourceOwner},
resource: map[string][]ResourceOwner{
"ns": {NamespaceResourceOwner},
},
check: []testCase{{
name: "readonly",
@@ -258,16 +257,16 @@ func TestAuthorizer_Authorize(t *testing.T) {
}},
}, {
name: "preferences teams",
teams: func(t *testing.T) utils.TeamService {
teams := utils.NewMockTeamService(t)
teams: func(t *testing.T) TeamService {
teams := NewMockTeamService(t)
teams.On("InTeam", mock.Anything, userABC, "xyz", false).Return(true, nil)
teams.On("InTeam", mock.Anything, userABC, "456", false).Return(false, nil)
teams.On("InTeam", mock.Anything, userABC, "XXX", false).Return(true, fmt.Errorf("error from team"))
return teams
},
resource: map[string][]utils.ResourceOwner{
resource: map[string][]ResourceOwner{
"preferences": {
utils.TeamResourceOwner,
TeamResourceOwner,
},
},
check: []testCase{{
@@ -318,11 +317,11 @@ func TestAuthorizer_Authorize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authz := &authorizeFromName{
resource: tt.resource,
authz := &AuthorizeFromName{
Resource: tt.resource,
}
if tt.teams != nil {
authz.teams = tt.teams(t)
authz.Teams = tt.teams(t)
}
for _, check := range tt.check {
t.Run(check.name, func(t *testing.T) {