Preferences: refactor experimental apiserver and improve tests (#111596)

This commit is contained in:
Ryan McKinley
2025-09-26 19:44:02 +00:00
committed by GitHub
parent a333e8a8da
commit 82bcfba64b
23 changed files with 946 additions and 290 deletions
+1
View File
@@ -139,6 +139,7 @@ var serviceIdentityTokenPermissions = []string{
"secret.grafana.app:*",
"query.grafana.app:*",
"iam.grafana.app:*",
"preferences.grafana.app:*",
// Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions.
"secret.grafana.app/securevalues:decrypt",
+90 -47
View File
@@ -6,55 +6,98 @@ import (
"k8s.io/apiserver/pkg/authorization/authorizer"
"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"
)
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
if !attr.IsResourceRequest() || user.GetIsGrafanaAdmin() || attr.GetName() == "" {
return authorizer.DecisionAllow, "", nil
}
name, found := utils.ParseOwnerFromName(attr.GetName())
if !found {
return authorizer.DecisionDeny, "invalid name", nil
}
if attr.GetResource() == "stars" && name.Owner != utils.UserResourceOwner {
return authorizer.DecisionDeny, "stars only support users", nil
}
switch name.Owner {
case utils.NamespaceResourceOwner:
return authorizer.DecisionAllow, "", nil
case utils.UserResourceOwner:
if user.GetUID() == name.Name {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "you may only fetch your own preferences", nil
case utils.TeamResourceOwner:
admin := !attr.IsReadOnly() // we need admin to for non read only commands
teams, err := b.sql.GetTeams(ctx, user.GetOrgID(), user.GetUID(), admin)
if err != nil {
return authorizer.DecisionDeny, "error fetching teams", err
}
if slices.Contains(teams, name.Name) {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "not a team member", nil
default:
}
return authorizer.DecisionDeny, "invalid name", nil
})
type authorizeFromName struct {
teams utils.TeamService
oknames []string
resource map[string][]utils.ResourceOwner // may include unknown
}
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
}
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
owners, ok := a.resource[attr.GetResource()]
if !ok {
return authorizer.DecisionDeny, "missing resource name", nil
}
// Check if the request includes explicit permissions
res := authz.CheckServicePermissions(user, attr.GetAPIGroup(), attr.GetResource(), attr.GetVerb())
if !res.Allowed {
log := logging.FromContext(ctx)
log.Info("calling service lacks required permissions",
"isServiceCall", res.ServiceCall,
"apiGroup", attr.GetAPIGroup(),
"resource", attr.GetResource(),
"verb", attr.GetVerb(),
"permissions", len(res.Permissions),
)
return authorizer.DecisionDeny, "calling service lacks required permissions", nil
}
if attr.GetName() == "" {
if attr.IsReadOnly() {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "mutating request without a name", nil
}
// the pseudo sub-resource
if a.oknames != nil && slices.Contains(a.oknames, attr.GetName()) {
return authorizer.DecisionAllow, "", nil
}
info, _ := utils.ParseOwnerFromName(attr.GetName())
if !slices.Contains(owners, info.Owner) {
return authorizer.DecisionDeny, "unsupported owner type", nil
}
switch info.Owner {
case utils.NamespaceResourceOwner:
if attr.IsReadOnly() {
// Everyone can see the namespace
return authorizer.DecisionAllow, "", nil
}
if user.GetOrgRole() == identity.RoleAdmin {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "must be an org admin to edit", nil
case utils.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 {
return authorizer.DecisionDeny, "team checker not configured", err
}
ok, err := a.teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly())
if err != nil {
return authorizer.DecisionDeny, "error fetching teams", err
}
if ok {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "you are not a member of the referenced team", nil
case utils.UnknownResourceOwner:
return authorizer.DecisionAllow, "", nil
}
// the owner was not explicitly allowed
return authorizer.DecisionDeny, "", nil
}
@@ -0,0 +1,351 @@
package preferences
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
)
type expect struct {
decision authorizer.Decision
reason string
err string
}
type testCase struct {
name string
user identity.Requester
attrs authorizer.Attributes
expect expect
breakpoint bool
}
func TestAuthorizer_Authorize(t *testing.T) {
userABC := &identity.StaticRequester{
UserUID: "abc",
OrgRole: identity.RoleViewer,
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
Rest: authn.AccessTokenClaims{
DelegatedPermissions: []string{"group/stars:*", "group/preferences:*", "group/ns:*"},
},
},
}
tests := []struct {
name string
teams func(t *testing.T) utils.TeamService
resource map[string][]utils.ResourceOwner
check []testCase
}{
{
name: "stars",
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
},
check: []testCase{{
name: "matches user",
user: userABC,
attrs: authorizer.AttributesRecord{
Verb: "get",
APIGroup: "group",
Resource: "stars",
Name: "user-abc", // note this matches in input user name
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionAllow,
},
}, {
name: "different user",
user: userABC,
attrs: authorizer.AttributesRecord{
Verb: "get",
APIGroup: "group",
Resource: "stars",
Name: "user-xyz", // not abc
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "your are not the owner of the resource",
},
}},
}, {
name: "fast path",
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
"preferences": {utils.TeamResourceOwner},
},
check: []testCase{{
name: "missing user",
attrs: authorizer.AttributesRecord{},
expect: expect{
decision: authorizer.DecisionDeny,
err: "a Requester was not found in the context",
},
}, {
name: "not a resource",
user: &identity.StaticRequester{},
attrs: authorizer.AttributesRecord{
ResourceRequest: false,
},
expect: expect{
decision: authorizer.DecisionNoOpinion,
},
}, {
name: "unknown resource",
user: &identity.StaticRequester{},
attrs: authorizer.AttributesRecord{
Resource: "xxxx",
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "missing resource name",
},
}, {
name: "missing service permissions",
user: &identity.StaticRequester{
UserUID: "abc",
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
Rest: authn.AccessTokenClaims{
DelegatedPermissions: []string{""},
},
},
},
attrs: authorizer.AttributesRecord{
Resource: "stars",
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "calling service lacks required permissions",
},
}, {
name: "wrong owner type",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "stars",
ResourceRequest: true,
Verb: "create", // missing name
Name: "team-xxx", // not supported
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "unsupported owner type",
},
}, {
name: "unknown resource",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "stars",
ResourceRequest: true,
Verb: "create", // missing name
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "mutating request without a name",
},
}, {
name: "list request",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "stars",
ResourceRequest: true,
Verb: "list", // no name
},
expect: expect{
decision: authorizer.DecisionAllow,
},
}, {
name: "teams request (but not configured)",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "preferences",
ResourceRequest: true,
Verb: "get",
Name: "team-XYZ",
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "team checker not configured",
},
}},
}, {
name: "unknown owner",
resource: map[string][]utils.ResourceOwner{
"stars": {utils.UnknownResourceOwner},
},
check: []testCase{{
name: "get",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "stars",
Name: "something-not-an-owner",
ResourceRequest: true,
Verb: "get",
},
expect: expect{
decision: authorizer.DecisionAllow,
},
}},
}, {
name: "namespace",
resource: map[string][]utils.ResourceOwner{
"ns": {utils.NamespaceResourceOwner},
},
check: []testCase{{
name: "readonly",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "ns",
ResourceRequest: true,
Verb: "get",
Name: "namespace",
},
expect: expect{
decision: authorizer.DecisionAllow,
},
}, {
name: "mutating",
user: userABC,
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "ns",
ResourceRequest: true,
Verb: "create",
Name: "namespace",
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "must be an org admin to edit",
},
}, {
name: "org admin",
user: &identity.StaticRequester{
UserUID: "abc",
OrgRole: identity.RoleAdmin,
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
Rest: authn.AccessTokenClaims{
DelegatedPermissions: []string{"group/ns:create"},
},
},
},
attrs: authorizer.AttributesRecord{
APIGroup: "group",
Resource: "ns",
ResourceRequest: true,
Verb: "create",
Name: "namespace",
},
expect: expect{
decision: authorizer.DecisionAllow,
},
}},
}, {
name: "preferences teams",
teams: func(t *testing.T) utils.TeamService {
teams := utils.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{
"preferences": {
utils.TeamResourceOwner,
},
},
check: []testCase{{
name: "user in team",
user: userABC,
attrs: authorizer.AttributesRecord{
Verb: "get",
APIGroup: "group",
Resource: "preferences",
Name: "team-xyz",
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionAllow,
},
}, {
name: "user not in team",
user: userABC,
attrs: authorizer.AttributesRecord{
Verb: "get",
APIGroup: "group",
Resource: "preferences",
Name: "team-456",
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "you are not a member of the referenced team",
},
}, {
name: "team error",
user: userABC,
attrs: authorizer.AttributesRecord{
Verb: "get",
APIGroup: "group",
Resource: "preferences",
Name: "team-XXX",
ResourceRequest: true,
},
expect: expect{
decision: authorizer.DecisionDeny,
reason: "error fetching teams",
err: "error from team",
},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authz := &authorizeFromName{
resource: tt.resource,
}
if tt.teams != nil {
authz.teams = tt.teams(t)
}
for _, check := range tt.check {
t.Run(check.name, func(t *testing.T) {
ctx := context.Background()
if check.user != nil {
ctx = identity.WithRequester(ctx, check.user)
}
e := check.expect
if check.breakpoint {
require.True(t, true) // Can set breakpoint in IDE here
}
d, r, err := authz.Authorize(ctx, check.attrs)
if e.err != "" {
require.ErrorContains(t, err, e.err)
return
}
require.NoError(t, err)
require.Equal(t, e.decision, d)
if e.reason != "" {
require.Equal(t, e.reason, r)
}
})
}
})
}
}
@@ -3,7 +3,6 @@ package legacy
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -85,34 +84,19 @@ func (s *preferenceStorage) Get(ctx context.Context, name string, options *metav
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
owner, ok := utils.ParseOwnerFromName(name)
if !ok {
return nil, preferences.PreferencesResourceInfo.NewNotFound(name)
}
// NOTE: the authorizer already checked if this request is allowed
found, _, err := s.sql.listPreferences(ctx, ns.Value, ns.OrgID, func(req *preferencesQuery) (bool, error) {
switch owner.Owner {
case utils.UserResourceOwner:
if !user.GetIsGrafanaAdmin() && name != user.GetUID() {
return false, fmt.Errorf("you may only fetch your own preferences")
}
req.UserUID = owner.Name
req.UserUID = owner.Identifier
return false, nil
case utils.TeamResourceOwner:
if !user.GetIsGrafanaAdmin() {
teams, err := s.sql.GetTeams(ctx, ns.OrgID, user.GetRawIdentifier(), false)
if err != nil {
return false, err
}
if !slices.Contains(teams, owner.Name) {
return false, fmt.Errorf("you may only fetch teams you belong to")
}
}
req.TeamUID = owner.Name
req.TeamUID = owner.Identifier
return false, nil
case utils.NamespaceResourceOwner:
return false, nil
@@ -136,13 +120,13 @@ func asPreferencesResource(ns string, p *preferenceModel) preferences.Preference
owner := utils.OwnerReference{}
if p.TeamUID.Valid {
owner.Owner = utils.TeamResourceOwner
owner.Name = p.TeamUID.String
owner.Identifier = p.TeamUID.String
} else if p.UserUID.Valid {
owner.Owner = utils.UserResourceOwner
owner.Name = p.UserUID.String
owner.Identifier = p.UserUID.String
} else {
owner.Owner = utils.NamespaceResourceOwner
owner.Name = ""
owner.Identifier = ""
}
obj := preferences.Preferences{
ObjectMeta: metav1.ObjectMeta{
+19 -3
View File
@@ -230,7 +230,10 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit
func(req *preferencesQuery) (bool, error) {
if user != nil {
req.UserUID = user.GetRawIdentifier()
teams, err = s.GetTeams(ctx, info.OrgID, req.UserUID, false)
teams, err = s.GetTeams(ctx, &identity.StaticRequester{
OrgID: info.OrgID,
UserUID: req.UserUID,
}, false)
req.UserTeams = teams
}
return needsRV, err
@@ -260,13 +263,26 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit
return list, nil
}
func (s *LegacySQL) GetTeams(ctx context.Context, orgId int64, user string, admin bool) ([]string, error) {
func (s *LegacySQL) InTeam(ctx context.Context, id authlib.AuthInfo, team string, admin bool) (bool, error) {
// Could be faster, but find for now
teams, err := s.GetTeams(ctx, id, admin)
if err != nil {
return false, err
}
return slices.Contains(teams, team), nil
}
func (s *LegacySQL) GetTeams(ctx context.Context, id authlib.AuthInfo, admin bool) ([]string, error) {
sql, err := s.db(ctx)
if err != nil {
return nil, err
}
req := newTeamsQueryReq(sql, orgId, user, admin)
xid, ok := id.(identity.Requester)
if !ok {
return nil, fmt.Errorf("expected identity.Requester")
}
req := newTeamsQueryReq(sql, xid.GetOrgID(), id.GetUID(), admin)
q, err := sqltemplate.Execute(sqlTeams, req)
if err != nil {
+15 -4
View File
@@ -18,6 +18,7 @@ import (
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"
@@ -91,8 +92,18 @@ func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversi
return nil, fmt.Errorf("cross cluster listing is not supported")
}
userInfo, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
user := userInfo.GetUID()
if userInfo.GetIsGrafanaAdmin() || userInfo.GetIdentityType() == authlib.TypeAccessPolicy {
user = "" // can see everything
}
list := &preferences.StarsList{}
found, rv, err := s.sql.GetStars(ctx, ns.OrgID, "")
found, rv, err := s.sql.GetStars(ctx, ns.OrgID, user)
if err != nil {
return nil, err
}
@@ -126,7 +137,7 @@ func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *m
return nil, err
}
found, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Name)
found, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Identifier)
if err != nil {
return nil, err
}
@@ -157,7 +168,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star
}
user, err := s.users.GetByUID(ctx, &user.GetUserByUIDQuery{
UID: owner.Name,
UID: owner.Identifier,
})
if err != nil {
return nil, err
@@ -176,7 +187,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star
}}, err
}
current, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Name)
current, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Identifier)
if err != nil {
return nil, err
}
@@ -18,13 +18,13 @@ import (
"github.com/grafana/grafana/pkg/util/errhttp"
)
type calculator struct {
type merger struct {
defaults preferences.PreferencesSpec
sql *legacy.LegacySQL
}
func newCalculator(cfg *setting.Cfg, sql *legacy.LegacySQL) *calculator {
return &calculator{
func newMerger(cfg *setting.Cfg, sql *legacy.LegacySQL) *merger {
return &merger{
sql: sql,
defaults: preferences.PreferencesSpec{
Theme: &cfg.DefaultTheme,
@@ -35,17 +35,17 @@ func newCalculator(cfg *setting.Cfg, sql *legacy.LegacySQL) *calculator {
}
}
func (s *calculator) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
func (s *merger) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
schema := defs["github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.Preference"].Schema
return &builder.APIRoutes{
Namespace: []builder.APIRouteHandler{
{
Path: "current", // calculate?
Path: "preferences/merged",
Spec: &spec3.PathProps{
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "currentPreferences",
OperationId: "mergedPreferences",
Tags: []string{"Preferences"},
Description: "Get preferences for requester. This combines the user preferences with the team and global defaults",
Parameters: []*spec3.Parameter{
@@ -87,7 +87,7 @@ func (s *calculator) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *bui
}
}
func (s *calculator) Current(w http.ResponseWriter, r *http.Request) {
func (s *merger) Current(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, err := identity.GetRequester(ctx)
if err != nil {
@@ -115,15 +115,18 @@ func (s *calculator) Current(w http.ResponseWriter, r *http.Request) {
// items should be in ascending order of importance
func merge(defaults preferences.PreferencesSpec, items []preferences.Preferences) (*preferences.Preferences, error) {
p := &preferences.Preferences{
TypeMeta: preferences.PreferencesResourceInfo.TypeMeta(),
ObjectMeta: v1.ObjectMeta{
CreationTimestamp: v1.Now(),
},
Spec: defaults,
TypeMeta: preferences.PreferencesResourceInfo.TypeMeta(),
ObjectMeta: v1.ObjectMeta{},
Spec: defaults,
}
// Iterate in reverse order (least relevant to most relevant)
for _, v := range items {
// Set the time from the most recent change
if p.CreationTimestamp.IsZero() || v.CreationTimestamp.After(p.CreationTimestamp.Time) {
p.CreationTimestamp = v.CreationTimestamp
}
if err := mergo.Merge(&p.Spec, &v.Spec, mergo.WithOverride); err != nil {
return nil, err
}
+43 -27
View File
@@ -6,6 +6,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
@@ -17,6 +18,7 @@ import (
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"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -30,13 +32,11 @@ import (
var _ builder.APIGroupBuilder = (*APIBuilder)(nil)
type APIBuilder struct {
namespacer request.NamespaceMapper
sql *legacy.LegacySQL
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
legacyPrefs rest.Storage
stars star.Service
prefs pref.Service
users user.Service
calculator *calculator // joins all preferences
merger *merger // joins all preferences
}
func RegisterAPIService(
@@ -55,13 +55,29 @@ func RegisterAPIService(
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
builder := &APIBuilder{
prefs: prefs, // for writing
stars: stars, // for writing
users: users, // for writing
namespacer: request.GetNamespaceMapper(cfg),
sql: sql,
calculator: newCalculator(cfg, sql),
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},
"preferences": {
utils.NamespaceResourceOwner,
utils.TeamResourceOwner,
utils.UserResourceOwner,
},
},
},
}
namespacer := request.GetNamespaceMapper(cfg)
if prefs != nil {
builder.legacyPrefs = legacy.NewPreferencesStorage(namespacer, sql)
}
if stars != nil {
builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql)
}
apiregistration.RegisterAPI(builder)
return builder
}
@@ -92,49 +108,49 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
// Configure Stars Dual writer
resource := preferences.StarsResourceInfo
var stars grafanarest.Storage
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
if err != nil {
return err
}
stars = unified
if b.stars != nil && opts.DualWriteBuilder != nil {
legacy := legacy.NewDashboardStarsStorage(b.stars, b.users, b.namespacer, b.sql)
stars, err = opts.DualWriteBuilder(resource.GroupResource(), legacy, unified)
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("write")] = &starsREST{
store: stars,
}
storage[resource.StoragePath("update")] = &starsREST{store: stars}
// Configure Preferences
prefs := preferences.PreferencesResourceInfo
storage[prefs.StoragePath()] = legacy.NewPreferencesStorage(b.namespacer, b.sql)
storage[prefs.StoragePath()] = b.legacyPrefs
apiGroupInfo.VersionedResourcesStorageMap[preferences.APIVersion] = storage
return nil
}
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return b.authorizer
}
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return preferences.GetOpenAPIDefinitions
}
func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.calculator.GetAPIRoutes(defs)
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() + "/"
writeKey := root + "namespaces/{namespace}/stars/{name}/write"
delete(oas.Paths.Paths, writeKey)
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[writeKey+"/{path}"]
stars, ok := oas.Paths.Paths[updateKey+"/{path}"]
if !ok || stars == nil {
return nil, fmt.Errorf("unable to find write path")
}
@@ -175,8 +191,8 @@ func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, err
stars.Delete.Description = "Remove a starred item"
stars.Delete.OperationId = "removeStar"
delete(oas.Paths.Paths, writeKey+"/{path}")
oas.Paths.Paths[writeKey+"/{group}/{kind}/{id}"] = stars
delete(oas.Paths.Paths, updateKey+"/{path}")
oas.Paths.Paths[updateKey+"/{group}/{kind}/{id}"] = stars
return oas, nil
}
@@ -64,12 +64,12 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object,
if !found || parsed.Owner != utils.UserResourceOwner {
return nil, fmt.Errorf("only works with user stars")
}
if user.GetIdentifier() != parsed.Name {
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/write", name))
item, err := itemFromPath(req.URL.Path, fmt.Sprintf("/%s/update", name))
if err != nil {
responder.Error(err)
return
+6 -6
View File
@@ -17,15 +17,15 @@ const (
)
type OwnerReference struct {
Owner ResourceOwner // the resource owner
Name string // the team|user name
Owner ResourceOwner // the resource owner
Identifier string // the team|user name
}
func (o OwnerReference) AsName() string {
if o.Name == "" || o.Owner == NamespaceResourceOwner {
if o.Identifier == "" || o.Owner == NamespaceResourceOwner {
return string(o.Owner)
}
return string(o.Owner) + "-" + o.Name
return string(o.Owner) + "-" + o.Identifier
}
func ParseOwnerFromName(name string) (OwnerReference, bool) {
@@ -33,9 +33,9 @@ func ParseOwnerFromName(name string) (OwnerReference, bool) {
if found && len(after) > 0 {
switch before {
case "user":
return OwnerReference{Owner: UserResourceOwner, Name: after}, true
return OwnerReference{Owner: UserResourceOwner, Identifier: after}, true
case "team":
return OwnerReference{Owner: TeamResourceOwner, Name: after}, true
return OwnerReference{Owner: TeamResourceOwner, Identifier: after}, true
}
} else if name == "namespace" {
return OwnerReference{Owner: NamespaceResourceOwner}, true
@@ -24,7 +24,7 @@ func TestLegacyAuthorizer(t *testing.T) {
{
name: "with user",
input: "user-a",
output: utils.OwnerReference{Owner: utils.UserResourceOwner, Name: "a"},
output: utils.OwnerReference{Owner: utils.UserResourceOwner, Identifier: "a"},
found: true,
},
{
@@ -36,7 +36,7 @@ func TestLegacyAuthorizer(t *testing.T) {
{
name: "with team",
input: "team-b",
output: utils.OwnerReference{Owner: utils.TeamResourceOwner, Name: "b"},
output: utils.OwnerReference{Owner: utils.TeamResourceOwner, Identifier: "b"},
found: true,
},
{
@@ -0,0 +1,13 @@
package utils
import (
"context"
authlib "github.com/grafana/authlib/types"
)
//go:generate mockery --name TeamService --structname MockTeamService --inpackage --filename teams_mock.go --with-expecter
type TeamService interface {
InTeam(ctx context.Context, id authlib.AuthInfo, team string, admin bool) (bool, error)
GetTeams(ctx context.Context, id authlib.AuthInfo, admin bool) ([]string, error)
}
@@ -0,0 +1,156 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package utils
import (
context "context"
types "github.com/grafana/authlib/types"
mock "github.com/stretchr/testify/mock"
)
// MockTeamService is an autogenerated mock type for the TeamService type
type MockTeamService struct {
mock.Mock
}
type MockTeamService_Expecter struct {
mock *mock.Mock
}
func (_m *MockTeamService) EXPECT() *MockTeamService_Expecter {
return &MockTeamService_Expecter{mock: &_m.Mock}
}
// GetTeams provides a mock function with given fields: ctx, id, admin
func (_m *MockTeamService) GetTeams(ctx context.Context, id types.AuthInfo, admin bool) ([]string, error) {
ret := _m.Called(ctx, id, admin)
if len(ret) == 0 {
panic("no return value specified for GetTeams")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, bool) ([]string, error)); ok {
return rf(ctx, id, admin)
}
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, bool) []string); ok {
r0 = rf(ctx, id, admin)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(context.Context, types.AuthInfo, bool) error); ok {
r1 = rf(ctx, id, admin)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockTeamService_GetTeams_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTeams'
type MockTeamService_GetTeams_Call struct {
*mock.Call
}
// GetTeams is a helper method to define mock.On call
// - ctx context.Context
// - id types.AuthInfo
// - admin bool
func (_e *MockTeamService_Expecter) GetTeams(ctx interface{}, id interface{}, admin interface{}) *MockTeamService_GetTeams_Call {
return &MockTeamService_GetTeams_Call{Call: _e.mock.On("GetTeams", ctx, id, admin)}
}
func (_c *MockTeamService_GetTeams_Call) Run(run func(ctx context.Context, id types.AuthInfo, admin bool)) *MockTeamService_GetTeams_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(types.AuthInfo), args[2].(bool))
})
return _c
}
func (_c *MockTeamService_GetTeams_Call) Return(_a0 []string, _a1 error) *MockTeamService_GetTeams_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockTeamService_GetTeams_Call) RunAndReturn(run func(context.Context, types.AuthInfo, bool) ([]string, error)) *MockTeamService_GetTeams_Call {
_c.Call.Return(run)
return _c
}
// InTeam provides a mock function with given fields: ctx, id, team, admin
func (_m *MockTeamService) InTeam(ctx context.Context, id types.AuthInfo, team string, admin bool) (bool, error) {
ret := _m.Called(ctx, id, team, admin)
if len(ret) == 0 {
panic("no return value specified for InTeam")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, string, bool) (bool, error)); ok {
return rf(ctx, id, team, admin)
}
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, string, bool) bool); ok {
r0 = rf(ctx, id, team, admin)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(context.Context, types.AuthInfo, string, bool) error); ok {
r1 = rf(ctx, id, team, admin)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockTeamService_InTeam_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InTeam'
type MockTeamService_InTeam_Call struct {
*mock.Call
}
// InTeam is a helper method to define mock.On call
// - ctx context.Context
// - id types.AuthInfo
// - team string
// - admin bool
func (_e *MockTeamService_Expecter) InTeam(ctx interface{}, id interface{}, team interface{}, admin interface{}) *MockTeamService_InTeam_Call {
return &MockTeamService_InTeam_Call{Call: _e.mock.On("InTeam", ctx, id, team, admin)}
}
func (_c *MockTeamService_InTeam_Call) Run(run func(ctx context.Context, id types.AuthInfo, team string, admin bool)) *MockTeamService_InTeam_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(types.AuthInfo), args[2].(string), args[3].(bool))
})
return _c
}
func (_c *MockTeamService_InTeam_Call) Return(_a0 bool, _a1 error) *MockTeamService_InTeam_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockTeamService_InTeam_Call) RunAndReturn(run func(context.Context, types.AuthInfo, string, bool) (bool, error)) *MockTeamService_InTeam_Call {
_c.Call.Return(run)
return _c
}
// NewMockTeamService creates a new instance of MockTeamService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockTeamService(t interface {
mock.TestingT
Cleanup(func())
}) *MockTeamService {
mock := &MockTeamService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -36,36 +36,6 @@
}
}
},
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/current": {
"get": {
"tags": [
"Preferences"
],
"description": "Get preferences for requester. This combines the user preferences with the team and global defaults",
"operationId": "currentPreferences",
"parameters": [
{
"name": "namespace",
"in": "path",
"description": "workspace",
"required": true,
"schema": {
"type": "string"
},
"example": "default"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/preferences": {
"get": {
"tags": [
@@ -224,6 +194,36 @@
}
]
},
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/preferences/merged": {
"get": {
"tags": [
"Preferences"
],
"description": "Get preferences for requester. This combines the user preferences with the team and global defaults",
"operationId": "mergedPreferences",
"parameters": [
{
"name": "namespace",
"in": "path",
"description": "workspace",
"required": true,
"schema": {
"type": "string"
},
"example": "default"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/preferences/{name}": {
"get": {
"tags": [
@@ -1122,7 +1122,7 @@
}
]
},
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}/write/{group}/{kind}/{id}": {
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}/update/{group}/{kind}/{id}": {
"put": {
"tags": [
"Stars"
@@ -127,15 +127,15 @@ func TestIntegrationPreferences(t *testing.T) {
`"regionalFormat":""
}`, string(jj))
current := apis.DoRequest(helper, apis.RequestParams{
merged := apis.DoRequest(helper, apis.RequestParams{
User: clientAdmin.Args.User,
Method: http.MethodGet,
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/current",
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
}, &preferences.Preferences{})
require.Equal(t, http.StatusOK, current.Response.StatusCode, "get current preferences")
require.Equal(t, "saturday", *current.Result.Spec.WeekStart) // from user
require.Equal(t, "africa", *current.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *current.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *current.Result.Spec.Language) // settings.ini
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
})
}
+147 -112
View File
@@ -13,7 +13,9 @@ import (
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util/testutil"
@@ -22,127 +24,160 @@ import (
func TestIntegrationStars(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for experimental APIs
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
},
})
t.Run("legacy dashboard stars", func(t *testing.T) {
ctx := context.Background()
starsClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
})
dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
for _, mode := range []grafanarest.DualWriterMode{
grafanarest.Mode0,
grafanarest.Mode2, // anything past 2 will fail
} {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for experimental APIs
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
},
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: mode,
},
"folders.folder.grafana.app": {
DualWriterMode: mode,
},
"stars.preferences.grafana.app": {
DualWriterMode: mode,
},
"preferences.preferences.grafana.app": {
DualWriterMode: mode,
},
},
})
// Create 5 dashboards
for i := range 5 {
_, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardV1.DashboardResourceInfo.GroupVersion().String(),
"kind": "Dashboard",
t.Run(fmt.Sprintf("test stars (mode:%d)", mode), func(t *testing.T) {
ctx := context.Background()
starsClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
})
starsClientViewer := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Viewer,
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
})
dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
})
// Create 5 dashboards
for i := range 5 {
_, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardV1.DashboardResourceInfo.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]any{
"name": fmt.Sprintf("test-%d", i),
},
"spec": map[string]any{
"title": fmt.Sprintf("test %d", i),
"schemaVersion": 42, // not really!
"panels": []any{},
},
},
}, metav1.CreateOptions{})
require.NoError(t, err)
}
found, err := dashboardClient.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, found.Items, 5, "should be 5 dashboards")
// List is empty when we start
rsp, err := starsClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Empty(t, rsp.Items, "no stars saved yet")
raw := make(map[string]any)
legacyResponse := apis.DoRequest(helper, apis.RequestParams{
User: starsClient.Args.User,
Method: http.MethodPost,
Path: "/api/user/stars/dashboard/uid/test-2",
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
User: starsClient.Args.User,
Method: http.MethodPost,
Path: "/api/user/stars/dashboard/uid/test-3",
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
// List values and compare results
rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
stars := typed(t, rsp, &preferences.StarsList{})
require.Len(t, stars.Items, 1, "user stars should exist")
require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(),
stars.Items[0].GetName(), "star resource for user")
resources := stars.Items[0].Spec.Resource
require.Len(t, resources, 1)
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
require.Equal(t, "Dashboard", resources[0].Kind)
require.ElementsMatch(t, []string{"test-2", "test-3"}, resources[0].Names)
// Remove one star
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
User: starsClient.Args.User,
Method: http.MethodDelete,
Path: "/api/user/stars/dashboard/uid/test-3",
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star")
rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
require.NoError(t, err)
after := typed(t, rspObj, &preferences.Stars{})
resources = after.Spec.Resource
require.Len(t, resources, 1)
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
require.Equal(t, "Dashboard", resources[0].Kind)
require.Equal(t, []string{"test-2"}, resources[0].Names)
// Change stars via k8s update
rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]any{
"name": fmt.Sprintf("test-%d", i),
"name": "user-" + starsClient.Args.User.Identity.GetIdentifier(),
"namespace": "default",
},
"spec": map[string]any{
"title": fmt.Sprintf("test %d", i),
"schemaVersion": 42, // not really!
"panels": []any{},
},
},
}, metav1.CreateOptions{})
require.NoError(t, err)
}
found, err := dashboardClient.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, found.Items, 5, "should be 5 dashboards")
// List is empty when we start
rsp, err := starsClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Empty(t, rsp.Items, "no stars saved yet")
raw := make(map[string]any)
legacyResponse := apis.DoRequest(helper, apis.RequestParams{
User: starsClient.Args.User,
Method: http.MethodPost,
Path: "/api/user/stars/dashboard/uid/test-2",
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
User: starsClient.Args.User,
Method: http.MethodPost,
Path: "/api/user/stars/dashboard/uid/test-3",
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
// List values and compare results
rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
stars := typed(t, rsp, &preferences.StarsList{})
require.Len(t, stars.Items, 1, "user stars should exist")
require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(),
stars.Items[0].GetName(), "star resource for user")
resources := stars.Items[0].Spec.Resource
require.Len(t, resources, 1)
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
require.Equal(t, "Dashboard", resources[0].Kind)
require.ElementsMatch(t, []string{"test-2", "test-3"}, resources[0].Names)
// Remove one star
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
User: starsClient.Args.User,
Method: http.MethodDelete,
Path: "/api/user/stars/dashboard/uid/test-3",
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star")
rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
require.NoError(t, err)
after := typed(t, rspObj, &preferences.Stars{})
resources = after.Spec.Resource
require.Len(t, resources, 1)
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
require.Equal(t, "Dashboard", resources[0].Kind)
require.Equal(t, []string{"test-2"}, resources[0].Names)
// Change stars via k8s update
rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]any{
"name": "user-" + starsClient.Args.User.Identity.GetIdentifier(),
"namespace": "default",
},
"spec": map[string]any{
"resource": []map[string]any{
{
"group": "dashboard.grafana.app",
"kind": "Dashboard",
"names": []string{"test-2", "aaa", "bbb"},
"resource": []map[string]any{
{
"group": "dashboard.grafana.app",
"kind": "Dashboard",
"names": []string{"test-2", "aaa", "bbb"},
},
},
},
},
},
}, metav1.UpdateOptions{})
require.NoError(t, err)
}, metav1.UpdateOptions{})
require.NoError(t, err)
after = typed(t, rspObj, &preferences.Stars{})
resources = after.Spec.Resource
require.Len(t, resources, 1)
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
require.Equal(t, "Dashboard", resources[0].Kind)
require.ElementsMatch(t,
[]string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb
resources[0].Names)
})
after = typed(t, rspObj, &preferences.Stars{})
resources = after.Spec.Resource
require.Len(t, resources, 1)
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
require.Equal(t, "Dashboard", resources[0].Kind)
require.ElementsMatch(t,
[]string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb
resources[0].Names)
// Viewer does not have any stars
rsp, err = starsClientViewer.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Empty(t, rsp.Items, "expect empty list")
// Not allowed to see another user's stars
rspObj, err = starsClientViewer.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
require.Error(t, err)
require.Nil(t, rspObj)
})
}
}
func typed[T any](t *testing.T, obj any, out T) T {