K8s: Library Panels: Add rerouting for get (#107362)

This commit is contained in:
Stephanie Hingtgen
2025-06-30 13:26:24 -05:00
committed by GitHub
parent 6cc26233fd
commit b1d3155b60
28 changed files with 1152 additions and 196 deletions
@@ -82,9 +82,12 @@ type LibraryPanelSpec struct {
// The panel type
PluginVersion string `json:"pluginVersion,omitempty"`
// The panel title
// The title of the library panel
Title string `json:"title,omitempty"`
// The title of the panel when displayed in the dashboard
PanelTitle string `json:"panelTitle,omitempty"`
// Library panel description
Description string `json:"description,omitempty"`
@@ -97,11 +100,28 @@ type LibraryPanelSpec struct {
// The default datasource type
Datasource *data.DataSourceRef `json:"datasource,omitempty"`
// The grid position
GridPos GridPos `json:"gridPos,omitempty"`
// Whether the panel is transparent
Transparent bool `json:"transparent,omitempty"`
// The links for the panel
Links []common.Unstructured `json:"links,omitempty"`
// The datasource queries
// +listType=set
Targets []data.DataQuery `json:"targets,omitempty"`
}
// +k8s:deepcopy-gen=true
type GridPos struct {
W int `json:"w"`
H int `json:"h"`
X int `json:"x"`
Y int `json:"y"`
}
// +k8s:deepcopy-gen=true
type LibraryPanelStatus struct {
// Translation warnings (mostly things that were in SQL columns but not found in the saved body)
@@ -9,6 +9,7 @@ package v0alpha1
import (
datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@@ -190,6 +191,22 @@ func (in *FacetResult) DeepCopy() *FacetResult {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GridPos) DeepCopyInto(out *GridPos) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GridPos.
func (in *GridPos) DeepCopy() *GridPos {
if in == nil {
return nil
}
out := new(GridPos)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LibraryPanel) DeepCopyInto(out *LibraryPanel) {
*out = *in
@@ -265,6 +282,14 @@ func (in *LibraryPanelSpec) DeepCopyInto(out *LibraryPanelSpec) {
*out = new(datav0alpha1.DataSourceRef)
**out = **in
}
out.GridPos = in.GridPos
if in.Links != nil {
in, out := &in.Links, &out.Links
*out = make([]commonv0alpha1.Unstructured, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Targets != nil {
in, out := &in.Targets, &out.Targets
*out = make([]datav0alpha1.DataQuery, len(*in))
@@ -29,6 +29,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionList(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardWithAccessInfo(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.FacetResult": schema_pkg_apis_dashboard_v0alpha1_FacetResult(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos": schema_pkg_apis_dashboard_v0alpha1_GridPos(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanel": schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelList(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref),
@@ -713,6 +714,47 @@ func schema_pkg_apis_dashboard_v0alpha1_FacetResult(ref common.ReferenceCallback
}
}
func schema_pkg_apis_dashboard_v0alpha1_GridPos(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"w": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int32",
},
},
"h": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int32",
},
},
"x": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int32",
},
},
"y": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int32",
},
},
},
Required: []string{"w", "h", "x", "y"},
},
},
}
}
func schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -833,7 +875,14 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
},
"title": {
SchemaProps: spec.SchemaProps{
Description: "The panel title",
Description: "The title of the library panel",
Type: []string{"string"},
Format: "",
},
},
"panelTitle": {
SchemaProps: spec.SchemaProps{
Description: "The title of the panel when displayed in the dashboard",
Type: []string{"string"},
Format: "",
},
@@ -863,6 +912,33 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef"),
},
},
"gridPos": {
SchemaProps: spec.SchemaProps{
Description: "The grid position",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos"),
},
},
"transparent": {
SchemaProps: spec.SchemaProps{
Description: "Whether the panel is transparent",
Type: []string{"boolean"},
Format: "",
},
},
"links": {
SchemaProps: spec.SchemaProps{
Description: "The links for the panel",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
},
},
},
"targets": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
@@ -886,7 +962,7 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
},
},
Dependencies: []string{
"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
}
}
@@ -1,6 +1,7 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,DashboardHit,Tags
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,DashboardMetadata,Finalizers
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,FacetResult,Terms
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,LibraryPanelSpec,Links
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,LibraryPanelStatus,Warnings
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,SearchResults,Hits
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,SortableFields,Fields
@@ -22,6 +22,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/setting"
@@ -75,6 +76,7 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err
provisioning,
nil, // no librarypanels.Service
sort.ProvideService(),
acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
)
client, err := newUnifiedClient(cfg, sqlStore)
+83 -32
View File
@@ -6,10 +6,12 @@ import (
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/authlib/types"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/libraryelements"
)
func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Authorizer {
@@ -42,38 +44,87 @@ func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Auth
return authorizer.DecisionDeny, "org mismatch", dashboards.ErrUserIsNotSignedInToOrg
}
switch attr.GetVerb() {
case "list", "search":
// Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not read any dashboards", err
}
case "create":
// Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not create any dashboards", err
}
case "get":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not view dashboard", err
}
case "update", "patch":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not edit dashboard", err
}
case "delete":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not delete dashboard", err
}
default:
l.Info("unknown verb", "verb", attr.GetVerb())
return authorizer.DecisionDeny, "unsupported verb", nil // Unknown verb
// Determine if this is a library panel or dashboard resource
resource := attr.GetResource()
isLibraryPanel := resource == dashv0.LIBRARY_PANEL_RESOURCE
if isLibraryPanel {
return authorizeLibraryPanel(ctx, ac, user, attr)
} else {
return authorizeDashboard(ctx, ac, user, attr)
}
return authorizer.DecisionAllow, "", nil
})
}
func authorizeLibraryPanel(ctx context.Context, ac accesscontrol.AccessControl, user identity.Requester, attr authorizer.Attributes) (authorizer.Decision, string, error) {
switch attr.GetVerb() {
case "list", "search":
// Detailed read permissions are handled by authz, this just checks whether the user can ready *any* library panel
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsRead))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not read any library panels", err
}
case "create":
// TODO: uncomment this when we implement create :)
//
// Detailed create permissions are handled by authz, this just checks whether the user can create *any* library panel
// ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsCreate))
// if !ok || err != nil {
// return authorizer.DecisionDeny, "can not create any library panels", err
// }
return authorizer.DecisionDeny, "can not create any library panels", nil
case "get":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsRead, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not view library panel", err
}
case "update", "patch":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsWrite, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not edit library panel", err
}
case "delete":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsDelete, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not delete library panel", err
}
default:
return authorizer.DecisionDeny, "unsupported verb for library panels", nil
}
return authorizer.DecisionAllow, "", nil
}
func authorizeDashboard(ctx context.Context, ac accesscontrol.AccessControl, user identity.Requester, attr authorizer.Attributes) (authorizer.Decision, string, error) {
switch attr.GetVerb() {
case "list", "search":
// Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not read any dashboards", err
}
case "create":
// Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not create any dashboards", err
}
case "get":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not view dashboard", err
}
case "update", "patch":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not edit dashboard", err
}
case "delete":
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
if !ok || err != nil {
return authorizer.DecisionDeny, "can not delete dashboard", err
}
default:
return authorizer.DecisionDeny, "unsupported verb for dashboards", nil
}
return authorizer.DecisionAllow, "", nil
}
@@ -15,6 +15,7 @@ import (
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
@@ -48,9 +49,10 @@ func ProvideLegacyMigrator(
sql db.DB, // direct access to tables
provisioning provisioning.ProvisioningService, // only needed for dashboard settings
libraryPanelSvc librarypanels.Service,
accessControl accesscontrol.AccessControl,
) LegacyMigrator {
dbp := legacysql.NewDatabaseProvider(sql)
return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService())
return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), accessControl)
}
type BlobStoreInfo struct {
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM {{ .Ident .LibraryElementTable }} as p
LEFT OUTER JOIN {{ .Ident .UserTable }} AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN {{ .Ident .UserTable }} AS updated_user ON p.updated_by = updated_user.id
@@ -25,9 +25,11 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
@@ -64,6 +66,7 @@ type dashboardSqlAccess struct {
dashStore dashboards.Store
dashboardSearchClient legacysearcher.DashboardSearchClient
accessControl accesscontrol.AccessControl
libraryPanelSvc librarypanels.Service
// Typically one... the server wrapper
@@ -78,6 +81,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
provisioning provisioning.ProvisioningService,
libraryPanelSvc librarypanels.Service,
sorter sort.Service,
accessControl accesscontrol.AccessControl,
) DashboardAccess {
dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
return &dashboardSqlAccess{
@@ -87,6 +91,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
provisioning: provisioning,
dashboardSearchClient: *dashboardSearchClient,
libraryPanelSvc: libraryPanelSvc,
accessControl: accessControl,
log: log.New("dashboard.legacysql"),
}
}
@@ -478,6 +483,25 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
return dash, created, err
}
type panel struct {
ID int64
UID string
FolderUID sql.NullString
Created time.Time
CreatedBy string
Updated time.Time
UpdatedBy string
Version int64
Name string
Type string
Description string
Model []byte
}
func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) {
limit := int(query.Limit)
query.Limit += 1 // for continue
@@ -485,6 +509,11 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library
return nil, fmt.Errorf("expected non zero orgID")
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
sqlx, err := a.sql(ctx)
if err != nil {
return nil, err
@@ -508,93 +537,30 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library
return nil, err
}
type panel struct {
ID int64
UID string
FolderUID sql.NullString
Created time.Time
CreatedBy string
Updated time.Time
UpdatedBy string
Name string
Type string
Description string
Model []byte
}
var lastID int64
for rows.Next() {
p := panel{}
err = rows.Scan(&p.ID, &p.UID, &p.FolderUID,
&p.Created, &p.CreatedBy,
&p.Updated, &p.UpdatedBy,
&p.Name, &p.Type, &p.Description, &p.Model,
&p.Name, &p.Type, &p.Description, &p.Model, &p.Version,
)
if err != nil {
return res, err
}
lastID = p.ID
item := dashboardV0.LibraryPanel{
TypeMeta: metav1.TypeMeta{
APIVersion: dashboardV0.APIVERSION,
Kind: "LibraryPanel",
},
ObjectMeta: metav1.ObjectMeta{
Name: p.UID,
CreationTimestamp: metav1.NewTime(p.Created),
ResourceVersion: strconv.FormatInt(p.Updated.UnixMicro(), 10),
},
Spec: dashboardV0.LibraryPanelSpec{},
}
status := &dashboardV0.LibraryPanelStatus{
Missing: v0alpha1.Unstructured{},
}
err = json.Unmarshal(p.Model, &item.Spec)
item, err := parseLibraryPanelRow(p)
if err != nil {
return nil, err
}
err = json.Unmarshal(p.Model, &status.Missing.Object)
if err != nil {
return nil, err
return res, err
}
if item.Spec.Title != p.Name {
status.Warnings = append(status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name))
}
if item.Spec.Description != p.Description {
status.Warnings = append(status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description))
}
if item.Spec.Type != p.Type {
status.Warnings = append(status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type))
}
item.Status = status
// Remove the properties we are already showing
for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel"} {
delete(status.Missing.Object, k)
}
meta, err := utils.MetaAccessor(&item)
if err != nil {
return nil, err
}
if p.FolderUID.Valid {
meta.SetFolder(p.FolderUID.String)
}
meta.SetCreatedBy(p.CreatedBy)
meta.SetGeneration(1)
meta.SetDeprecatedInternalID(p.ID) //nolint:staticcheck
// Only set updated metadata if it is different
if p.UpdatedBy != p.CreatedBy || p.Updated.Sub(p.Created) > time.Second {
meta.SetUpdatedBy(p.UpdatedBy)
meta.SetUpdatedTimestamp(&p.Updated)
meta.SetGeneration(2)
ok, err := a.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission(
libraryelements.ActionLibraryPanelsRead,
libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(item.Name),
))
if err != nil || !ok {
continue
}
res.Items = append(res.Items, item)
@@ -611,3 +577,71 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library
}
return res, err
}
func parseLibraryPanelRow(p panel) (dashboardV0.LibraryPanel, error) {
item := dashboardV0.LibraryPanel{
TypeMeta: metav1.TypeMeta{
APIVersion: dashboardV0.APIVERSION,
Kind: "LibraryPanel",
},
ObjectMeta: metav1.ObjectMeta{
Name: p.UID,
CreationTimestamp: metav1.NewTime(p.Created),
ResourceVersion: strconv.FormatInt(p.Updated.UnixMicro(), 10),
},
Spec: dashboardV0.LibraryPanelSpec{},
}
status := &dashboardV0.LibraryPanelStatus{
Missing: v0alpha1.Unstructured{},
}
err := json.Unmarshal(p.Model, &item.Spec)
if err != nil {
return item, err
}
err = json.Unmarshal(p.Model, &status.Missing.Object)
if err != nil {
return item, err
}
// the panel title used in dashboards and title of the library panel can differ
// in the old model blob, the panel title is specified as "title", and the library panel title is
// in "libraryPanel.name", or as the column in the db.
item.Spec.PanelTitle = item.Spec.Title
item.Spec.Title = p.Name
if item.Spec.Title != p.Name {
status.Warnings = append(status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name))
}
if item.Spec.Description != p.Description {
status.Warnings = append(status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description))
}
if item.Spec.Type != p.Type {
status.Warnings = append(status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type))
}
item.Status = status
// Remove the properties we are already showing
for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel", "id", "gridPos"} {
delete(status.Missing.Object, k)
}
meta, err := utils.MetaAccessor(&item)
if err != nil {
return item, err
}
if p.FolderUID.Valid {
meta.SetFolder(p.FolderUID.String)
}
meta.SetCreatedBy(p.CreatedBy)
meta.SetGeneration(p.Version)
meta.SetDeprecatedInternalID(p.ID) //nolint:staticcheck
// Only set updated metadata if it is different
if p.UpdatedBy != p.CreatedBy || p.Updated.Sub(p.Created) > time.Second {
meta.SetUpdatedBy(p.UpdatedBy)
meta.SetUpdatedTimestamp(&p.Updated)
}
return item, nil
}
@@ -2,6 +2,8 @@ package legacy
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
@@ -221,3 +223,81 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
})
}
}
func TestParseLibraryPanelRow(t *testing.T) {
basePanel := panel{
ID: 1,
UID: "panel-uid",
FolderUID: sql.NullString{String: "folder-uid", Valid: true},
Created: time.Now(),
CreatedBy: "creator",
Updated: time.Now(),
UpdatedBy: "updator",
Version: 2,
Type: "graph",
Description: "desc from db",
}
t.Run("type mismatch triggers warning", func(t *testing.T) {
p := basePanel
p.Name = "Test Panel"
p.Type = "graph"
model := map[string]interface{}{
"title": "Test Panel",
"type": "table",
"description": "desc from db",
}
modelBytes, err := json.Marshal(model)
require.NoError(t, err)
p.Model = modelBytes
item, err := parseLibraryPanelRow(p)
require.NoError(t, err)
require.Equal(t, "table", item.Spec.Type)
require.NotEmpty(t, item.Status.Warnings)
require.Contains(t, item.Status.Warnings[0], "type mismatch")
})
t.Run("metadata is set correctly", func(t *testing.T) {
p := basePanel
p.Name = "Test Panel"
model := map[string]interface{}{
"title": "Test Panel",
"type": "graph",
"description": "desc from db",
}
modelBytes, err := json.Marshal(model)
require.NoError(t, err)
p.Model = modelBytes
item, err := parseLibraryPanelRow(p)
require.NoError(t, err)
meta, err := utils.MetaAccessor(&item)
require.NoError(t, err)
require.Equal(t, p.ID, meta.GetDeprecatedInternalID()) // nolint:staticcheck
require.Equal(t, p.Version, meta.GetGeneration())
require.Equal(t, p.FolderUID.String, meta.GetFolder())
require.Equal(t, p.CreatedBy, meta.GetCreatedBy())
require.Equal(t, p.UpdatedBy, meta.GetUpdatedBy())
})
t.Run("panel title in dashboard vs library panel title is set correctly", func(t *testing.T) {
p := basePanel
p.Name = "Database Name"
model := map[string]interface{}{
"title": "Model Title",
"type": "graph",
"description": "desc from db",
}
modelBytes, err := json.Marshal(model)
require.NoError(t, err)
p.Model = modelBytes
item, err := parseLibraryPanelRow(p)
require.NoError(t, err)
require.Equal(t, "Model Title", item.Spec.PanelTitle)
require.Equal(t, "Database Name", item.Spec.Title)
})
}
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM `grafana`.`library_element` as p
LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM `grafana`.`library_element` as p
LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM `grafana`.`library_element` as p
LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM "grafana"."library_element" as p
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM "grafana"."library_element" as p
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM "grafana"."library_element" as p
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM "grafana"."library_element" as p
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM "grafana"."library_element" as p
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
@@ -1,7 +1,7 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
p.name, p.type, p.description, p.model, p.version
FROM "grafana"."library_element" as p
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
+23 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
@@ -21,12 +22,16 @@ var (
_ rest.SingularNameProvider = (*LibraryPanelStore)(nil)
_ rest.Getter = (*LibraryPanelStore)(nil)
_ rest.Lister = (*LibraryPanelStore)(nil)
_ rest.Creater = (*LibraryPanelStore)(nil)
_ rest.GracefulDeleter = (*LibraryPanelStore)(nil)
_ rest.Updater = (*LibraryPanelStore)(nil)
_ rest.Storage = (*LibraryPanelStore)(nil)
)
type LibraryPanelStore struct {
Access legacy.DashboardAccess
ResourceInfo utils.ResourceInfo
Access legacy.DashboardAccess
ResourceInfo utils.ResourceInfo
AccessControl accesscontrol.AccessControl
}
func (s *LibraryPanelStore) New() runtime.Object {
@@ -51,6 +56,22 @@ func (s *LibraryPanelStore) ConvertToTable(ctx context.Context, object runtime.O
return s.ResourceInfo.TableConverter().ConvertToTable(ctx, object, tableOptions)
}
func (s *LibraryPanelStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
return nil, fmt.Errorf("method not yet implemented")
}
func (s *LibraryPanelStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
return nil, false, fmt.Errorf("method not yet implemented")
}
func (s *LibraryPanelStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, fmt.Errorf("method not yet implemented")
}
func (s *LibraryPanelStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
return nil, apierrors.NewMethodNotSupported(s.ResourceInfo.GroupResource(), "deletecollection")
}
func (s *LibraryPanelStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
if options.ResourceVersion != "" {
return nil, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend")
+16 -4
View File
@@ -142,7 +142,7 @@ func RegisterAPIService(
folderClient: folderClient,
legacy: &DashboardStorage{
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter),
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, accessControl),
DashboardService: dashboardService,
},
reg: reg,
@@ -519,9 +519,21 @@ func (b *DashboardsAPIBuilder) storageForVersion(
// Expose read only library panels
if libraryPanels != nil {
storage[libraryPanels.StoragePath()] = &LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
legacyLibraryStore := &LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
AccessControl: b.accessControl,
}
unifiedLibraryStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *libraryPanels, opts.OptsGetter)
if err != nil {
return err
}
libraryGr := libraryPanels.GroupResource()
storage[libraryPanels.StoragePath()], err = opts.DualWriteBuilder(libraryGr, legacyLibraryStore, unifiedLibraryStore)
if err != nil {
return err
}
}
@@ -438,7 +438,7 @@ func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) {
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac, b)
require.NoError(t, err)
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac, dashSrv)
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac, dashSrv, nil, nil)
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn)
require.NoError(t, err)
@@ -517,7 +517,7 @@ func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) {
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b)
require.NoError(t, err)
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac, dashSrv)
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac, dashSrv, nil, nil)
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff)
require.NoError(t, err)
@@ -655,7 +655,7 @@ func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) {
require.NoError(t, err)
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac, dashSrv)
elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac, dashSrv, nil, nil)
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, tc.service)
require.NoError(t, err)
+227 -5
View File
@@ -1,23 +1,38 @@
package libraryelements
import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"net/http"
"strings"
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errhttp"
"github.com/grafana/grafana/pkg/web"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
)
func (l *LibraryElementService) registerAPIEndpoints() {
@@ -25,13 +40,13 @@ func (l *LibraryElementService) registerAPIEndpoints() {
l.RouteRegister.Group("/api/library-elements", func(entities routing.RouteRegister) {
uidScope := ScopeLibraryPanelsProvider.GetResourceScopeUID(ac.Parameter(":uid"))
entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler))
entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler))
entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler))
entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler)) // TODO: add wrapper for k8s
entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler)) // TODO: add wrapper for k8s
entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler)) // TODO: add wrapper for k8s - requires search
entities.Get("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getHandler))
entities.Get("/:uid/connections/", authorize(ac.EvalPermission(ActionLibraryPanelsRead, uidScope)), routing.Wrap(l.getConnectionsHandler))
entities.Get("/name/:name", routing.Wrap(l.getByNameHandler))
entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler))
entities.Get("/name/:name", routing.Wrap(l.getByNameHandler)) // TODO: add wrapper for k8s - requires search
entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler)) // TODO: add wrapper for k8s
})
}
@@ -133,6 +148,11 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response {
if l.features.IsEnabled(c.Req.Context(), featuremgmt.FlagKubernetesLibraryPanels) {
l.k8sHandler.getK8sLibraryElement(c)
return nil // already handled in the k8s handler
}
ctx := c.Req.Context()
element, err := l.getLibraryElementByUid(ctx, c.SignedInUser,
model.GetLibraryElementCommand{
@@ -529,3 +549,205 @@ type GetLibraryElementConnectionsResponse struct {
// in: body
Body model.LibraryElementConnectionsResponse `json:"body"`
}
//-----------------------------------------------------------------------------------------
// Library Elements k8s wrapper functions
//-----------------------------------------------------------------------------------------
type libraryElementsK8sHandler struct {
cfg *setting.Cfg
namespacer request.NamespaceMapper
gvr schema.GroupVersionResource
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
folderService folder.Service
dashboardsService dashboards.DashboardService
userService user.Service
}
func newLibraryElementsK8sHandler(cfg *setting.Cfg, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, folderService folder.Service, userService user.Service, dashboardsService dashboards.DashboardService) *libraryElementsK8sHandler {
gvr := schema.GroupVersionResource{
Group: dashboardV0.APIGroup,
Version: dashboardV0.APIVersion,
Resource: dashboardV0.LIBRARY_PANEL_RESOURCE,
}
return &libraryElementsK8sHandler{
cfg: cfg,
gvr: gvr,
namespacer: request.GetNamespaceMapper(cfg),
clientConfigProvider: clientConfigProvider,
folderService: folderService,
dashboardsService: dashboardsService,
userService: userService,
}
}
func (lk8s *libraryElementsK8sHandler) getK8sLibraryElement(c *contextmodel.ReqContext) {
client, ok := lk8s.getClient(c)
if !ok {
return
}
uid := web.Params(c.Req)[":uid"]
out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{})
if err != nil {
lk8s.writeError(c, err)
return
}
dto, err := lk8s.unstructuredToLegacyLibraryPanelDTO(c, *out)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "conversion error", err)
return
}
c.JSON(http.StatusOK, model.LibraryElementResponse{Result: *dto})
}
func (lk8s *libraryElementsK8sHandler) unstructuredToLegacyLibraryPanelDTO(c *contextmodel.ReqContext, item unstructured.Unstructured) (*model.LibraryElementDTO, error) {
spec, exists := item.Object["spec"].(map[string]interface{})
if !exists {
return nil, fmt.Errorf("spec not found in unstructured object")
}
id := int64(0)
folderUID := ""
meta, err := utils.MetaAccessor(&item)
if err == nil {
id = meta.GetDeprecatedInternalID() // nolint:staticcheck
folderUID = meta.GetFolder()
}
var libraryPanelSpec dashboardV0.LibraryPanelSpec
specJSON, err := json.Marshal(spec)
if err != nil {
return nil, fmt.Errorf("failed to marshal spec: %w", err)
}
err = json.Unmarshal(specJSON, &libraryPanelSpec)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal spec into LibraryPanelSpec: %w", err)
}
// need to reconstruct this section from what we have in the k8s object
legacyModel := map[string]any{}
legacyModel["datasource"] = libraryPanelSpec.Datasource
legacyModel["description"] = libraryPanelSpec.Description
legacyModel["fieldConfig"] = libraryPanelSpec.FieldConfig.Object
legacyModel["gridPos"] = libraryPanelSpec.GridPos
legacyModel["id"] = id
legacyModel["options"] = libraryPanelSpec.Options.Object
legacyModel["pluginVersion"] = libraryPanelSpec.PluginVersion
legacyModel["type"] = libraryPanelSpec.Type
legacyModel["title"] = libraryPanelSpec.PanelTitle // this is the title of the panel when displayed in the dashboard
legacyModel["libraryPanel"] = map[string]string{
"name": libraryPanelSpec.Title, // this is the title of the actual library panel, when displayed in the library panel list
"uid": item.GetName(),
}
if len(libraryPanelSpec.Links) > 0 {
legacyModel["links"] = libraryPanelSpec.Links
}
if len(libraryPanelSpec.Targets) > 0 {
legacyModel["targets"] = libraryPanelSpec.Targets
}
if libraryPanelSpec.Transparent {
legacyModel["transparent"] = libraryPanelSpec.Transparent
}
finalModel, err := json.Marshal(legacyModel)
if err != nil {
return nil, fmt.Errorf("failed to marshal model: %w", err)
}
dto := &model.LibraryElementDTO{
ID: id,
OrgID: c.OrgID,
FolderUID: folderUID,
UID: item.GetName(),
Name: libraryPanelSpec.Title,
Kind: int64(model.PanelElement),
Type: libraryPanelSpec.Type,
Description: libraryPanelSpec.Description,
Model: finalModel,
Version: item.GetGeneration(),
Meta: model.LibraryElementDTOMeta{
FolderUID: folderUID,
Created: meta.GetCreationTimestamp().Time,
},
}
if folderUID != "" {
folder, err := lk8s.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{
OrgID: c.OrgID,
UID: &folderUID,
SignedInUser: c.SignedInUser,
})
if err != nil {
return nil, err
}
dto.Meta.FolderName = folder.Title
dto.FolderID = folder.ID // nolint:staticcheck
}
dashboards, err := lk8s.dashboardsService.GetDashboardsByLibraryPanelUID(c.Req.Context(), item.GetName(), c.OrgID)
if err != nil {
return nil, err
}
dto.Meta.ConnectedDashboards = int64(len(dashboards))
createdBy := meta.GetCreatedBy()
updatedBy := createdBy // the old /api returns the same user for updated if it was never updated
userUIDs := []string{meta.GetCreatedBy()}
if timestamp, err := meta.GetUpdatedTimestamp(); err == nil && timestamp != nil {
dto.Meta.Updated = *timestamp
updatedBy = meta.GetUpdatedBy()
userUIDs = append(userUIDs, updatedBy)
} else {
// if never updated, the old /api returns the same timestamp for updated as for created
dto.Meta.Updated = dto.Meta.Created
}
users, err := lk8s.userService.ListByIdOrUID(c.Req.Context(), userUIDs, []int64{c.OrgID})
if err != nil {
return nil, err
}
for _, user := range users {
if user.UID == createdBy {
dto.Meta.CreatedBy = librarypanel.LibraryElementDTOMetaUser{
Id: user.ID,
Name: user.Login,
AvatarUrl: dtos.GetGravatarUrl(lk8s.cfg, user.Email),
}
}
// not else because /api returns the same user for updated if it was never updated
if user.UID == updatedBy {
dto.Meta.UpdatedBy = librarypanel.LibraryElementDTOMetaUser{
Id: user.ID,
Name: user.Login,
AvatarUrl: dtos.GetGravatarUrl(lk8s.cfg, user.Email),
}
}
}
return dto, nil
}
//-----------------------------------------------------------------------------------------
// Utility functions
//-----------------------------------------------------------------------------------------
func (lk8s *libraryElementsK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) {
dyn, err := dynamic.NewForConfig(lk8s.clientConfigProvider.GetDirectRestConfig(c))
if err != nil {
c.JsonApiErr(500, "client", err)
return nil, false
}
return dyn.Resource(lk8s.gvr).Namespace(lk8s.namespacer(c.OrgID)), true
}
func (lk8s *libraryElementsK8sHandler) writeError(c *contextmodel.ReqContext, err error) {
//nolint:errorlint
statusError, ok := err.(*k8serrors.StatusError)
if ok {
c.JsonApiErr(int(statusError.Status().Code), statusError.Status().Message, err)
return
}
errhttp.Write(c.Req.Context(), err, c.Resp)
}
@@ -0,0 +1,180 @@
package libraryelements
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/apimachinery/utils"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestUnstructuredToLegacyLibraryPanelDTO(t *testing.T) {
cfg := setting.NewCfg()
userSvc := &usertest.FakeUserService{}
testUser := &user.User{
ID: 1,
UID: "test-user-uid",
Login: "testuser",
Email: "test@example.com",
}
userSvc.ExpectedListUsersByIdOrUid = []*user.User{testUser}
testFolder := &folder.Folder{
ID: 1,
UID: "test-folder-uid",
Title: "Test Folder",
}
folderSvc := &foldertest.FakeService{
ExpectedFolder: testFolder,
}
dashboardsSvc := &dashboards.FakeDashboardService{}
testDashboard := &dashboards.DashboardRef{
ID: 1,
UID: "test-dashboard-uid",
FolderUID: testFolder.UID,
}
dashboardsSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, "test-panel-uid", int64(1)).Return([]*dashboards.DashboardRef{testDashboard}, nil)
handler := &libraryElementsK8sHandler{
cfg: cfg,
folderService: folderSvc,
dashboardsService: dashboardsSvc,
userService: userSvc,
}
unstructuredObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "dashboard.grafana.app/v0alpha1",
"kind": "LibraryPanel",
"metadata": map[string]any{
"name": "test-panel-uid",
},
"spec": map[string]any{
"type": "text",
"pluginVersion": "1.0.0",
"title": "Test Library Panel",
"panelTitle": "Test Panel Title",
"description": "Test description",
"options": map[string]interface{}{
"content": "Test content",
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"color": map[string]interface{}{
"mode": "palette-classic",
},
},
},
"gridPos": map[string]interface{}{
"h": 8,
"w": 12,
"x": 0,
"y": 0,
},
"datasource": map[string]interface{}{
"type": "testdata",
"uid": "test-datasource",
},
"transparent": true,
"links": []interface{}{
map[string]interface{}{
"title": "Test Link",
"url": "https://example.com",
},
},
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"expr": "test_query",
},
},
},
},
}
meta, err := utils.MetaAccessor(unstructuredObj)
require.NoError(t, err)
meta.SetFolder(testFolder.UID)
meta.SetGeneration(2)
creationTimestamp := metav1.NewTime(time.Now())
meta.SetCreationTimestamp(creationTimestamp)
meta.SetCreatedBy(testUser.UID)
meta.SetDeprecatedInternalID(123) // nolint:staticcheck
reqContext := &contextmodel.ReqContext{
Context: &web.Context{
Req: httptest.NewRequest("GET", "/", nil).WithContext(context.Background()),
},
SignedInUser: &user.SignedInUser{
UserID: 1,
OrgID: 1,
OrgRole: org.RoleAdmin,
},
}
result, err := handler.unstructuredToLegacyLibraryPanelDTO(reqContext, *unstructuredObj)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, int64(123), result.ID)
require.Equal(t, int64(1), result.OrgID)
require.Equal(t, testFolder.UID, result.FolderUID)
require.Equal(t, "test-panel-uid", result.UID)
require.Equal(t, "Test Library Panel", result.Name)
require.Equal(t, int64(model.PanelElement), result.Kind)
require.Equal(t, "text", result.Type)
require.Equal(t, "Test description", result.Description)
require.Equal(t, int64(2), result.Version)
require.Equal(t, testFolder.UID, result.Meta.FolderUID)
require.Equal(t, testFolder.Title, result.Meta.FolderName)
require.Equal(t, int64(1), result.Meta.ConnectedDashboards)
require.Equal(t, int64(1), result.FolderID) // nolint:staticcheck
require.Equal(t, creationTimestamp.Format(time.RFC3339), result.Meta.Created.Format(time.RFC3339))
require.Equal(t, testUser.ID, result.Meta.CreatedBy.Id)
require.Equal(t, testUser.Login, result.Meta.CreatedBy.Name)
require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.CreatedBy.AvatarUrl)
require.Equal(t, creationTimestamp.Format(time.RFC3339), result.Meta.Updated.Format(time.RFC3339))
require.Equal(t, testUser.ID, result.Meta.UpdatedBy.Id)
require.Equal(t, testUser.Login, result.Meta.UpdatedBy.Name)
require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.UpdatedBy.AvatarUrl)
var modelMap map[string]interface{}
err = json.Unmarshal(result.Model, &modelMap)
require.NoError(t, err)
require.Equal(t, "testdata", modelMap["datasource"].(map[string]interface{})["type"])
require.Equal(t, "test-datasource", modelMap["datasource"].(map[string]interface{})["uid"])
require.Equal(t, "Test description", modelMap["description"])
require.Equal(t, float64(123), modelMap["id"])
require.Equal(t, "text", modelMap["type"])
require.Equal(t, "Test Panel Title", modelMap["title"])
require.Equal(t, "Test content", modelMap["options"].(map[string]interface{})["content"])
require.Equal(t, true, modelMap["transparent"])
require.Equal(t, "Test Library Panel", modelMap["libraryPanel"].(map[string]interface{})["name"])
require.Equal(t, "test-panel-uid", modelMap["libraryPanel"].(map[string]interface{})["uid"])
links := modelMap["links"].([]interface{})
require.Len(t, links, 1)
require.Equal(t, "Test Link", links[0].(map[string]interface{})["title"])
targets := modelMap["targets"].([]interface{})
require.Len(t, targets, 1)
require.Equal(t, "A", targets[0].(map[string]interface{})["refId"])
dashboardsSvc.AssertExpectations(t)
}
@@ -9,14 +9,16 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, dashboardsService dashboards.DashboardService) *LibraryElementService {
func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, dashboardsService dashboards.DashboardService, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, userService user.Service) *LibraryElementService {
l := &LibraryElementService{
Cfg: cfg,
SQLStore: sqlStore,
@@ -26,6 +28,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
log: log.New("library-elements"),
features: features,
AccessControl: ac,
k8sHandler: newLibraryElementsK8sHandler(cfg, clientConfigProvider, folderService, userService, dashboardsService),
}
l.registerAPIEndpoints()
@@ -55,6 +58,7 @@ type LibraryElementService struct {
log log.Logger
features featuremgmt.FeatureToggles
AccessControl accesscontrol.AccessControl
k8sHandler *libraryElementsK8sHandler
}
var _ Service = (*LibraryElementService)(nil)
@@ -850,7 +850,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore,
nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig)
elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService)
elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService, nil, nil)
service := LibraryPanelService{
Cfg: cfg,
SQLStore: sqlStore,
@@ -2419,68 +2419,6 @@ func runDashboardListTest(t *testing.T, ctx TestContext) {
})
}
// TODO: this only works on mode0-3 right now. In modes 4/5, we need to start returning the connections endpoint
// from retrieving the panel count from search / indexing the dashboard library panels
func TestDashboardWithLibraryPanel(t *testing.T) {
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3}
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
"unifiedStorageSearch",
"kubernetesClientDashboardsFolders",
},
})
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
// create the library element first
libraryElement := map[string]interface{}{
"kind": 1,
"name": "Test Library Panel",
"model": map[string]interface{}{
"type": "timeseries",
"title": "Test Library Panel",
},
}
libraryElementURL := "/api/library-elements"
libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser)
require.NoError(t, err)
require.NotNil(t, libraryElementData)
data := libraryElementData["result"].(map[string]interface{})
uid := data["uid"].(string)
require.NotEmpty(t, uid)
// then reference the library element in the dashboard
dashboard := createDashboardObject(t, "Library Panel Test", "", 1)
dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{
map[string]interface{}{
"id": 1,
"title": "Library Panel",
"type": "library-panel-ref",
"libraryPanel": map[string]interface{}{
"uid": uid,
"name": "Test Library Panel",
},
},
}
createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, createdDash)
// should have created a library panel connection
connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid)
connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser)
require.NoError(t, err)
require.NotNil(t, connectionsData)
connections := connectionsData["result"].([]interface{})
require.Len(t, connections, 1)
})
}
}
func postHelper(t *testing.T, ctx *TestContext, path string, body interface{}, user apis.User) (map[string]interface{}, error) {
bodyJSON, err := json.Marshal(body)
require.NoError(t, err)
@@ -0,0 +1,288 @@
package integration
import (
"context"
"fmt"
"net/http"
"testing"
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
)
// this tests the /api path still, but behind the scenes is using search to get the library connections
// as in modes 3+, the connections are found via searching dashboards for the reference of the library panel
//
// it also ensures we create the connection in modes 0-2 if a dashboard v1 is created with a reference
func TestIntegrationLibraryPanelConnections(t *testing.T) {
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
"unifiedStorageSearch",
"kubernetesClientDashboardsFolders",
"kubernetesLibraryPanels",
},
})
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
// create the library element first
libraryElement := map[string]interface{}{
"kind": 1,
"name": "Test Library Panel",
"model": map[string]interface{}{
"type": "timeseries",
"title": "Test Library Panel",
},
}
libraryElementURL := "/api/library-elements"
libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser)
require.NoError(t, err)
require.NotNil(t, libraryElementData)
data := libraryElementData["result"].(map[string]interface{})
uid := data["uid"].(string)
require.NotEmpty(t, uid)
// then reference the library element in the dashboard
dashboard := createDashboardObject(t, "Library Panel Test", "", 1)
dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{
map[string]interface{}{
"id": 1,
"title": "Library Panel",
"type": "library-panel-ref",
"libraryPanel": map[string]interface{}{
"uid": uid,
"name": "Test Library Panel",
},
},
}
createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, createdDash)
// should have created a library panel connection
connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid)
connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser)
require.NoError(t, err)
require.NotNil(t, connectionsData)
connections := connectionsData["result"].([]interface{})
require.Len(t, connections, 1)
})
}
}
// this tests the /apis path to ensure authorization is being enforced. /api integration tests are within the service package
// only works in modes 0-2 because the library element is created through the /api path
func TestIntegrationLibraryElementPermissions(t *testing.T) {
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2}
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
"unifiedStorageSearch",
"kubernetesClientDashboardsFolders",
"kubernetesLibraryPanels",
"grafanaAPIServerWithExperimentalAPIs", // needed until we move it to v0beta1 at least (currently v0alpha1)
},
})
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
t.Run("Library element authorization tests", func(t *testing.T) {
runLibraryElementAuthorizationTests(t, ctx)
})
t.Run("Library element cross-organization tests", func(t *testing.T) {
org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode)
runLibraryElementCrossOrgTests(t, ctx, org2Ctx)
})
})
}
}
func runLibraryElementAuthorizationTests(t *testing.T, ctx TestContext) {
t.Helper()
roles := []string{"Admin", "Editor", "Viewer"}
for _, role := range roles {
t.Run(role, func(t *testing.T) {
var client *apis.K8sResourceClient
switch role {
case "Admin":
client = getResourceClient(t, ctx.Helper, ctx.AdminUser, getLibraryElementGVR())
case "Editor":
client = getResourceClient(t, ctx.Helper, ctx.EditorUser, getLibraryElementGVR())
case "Viewer":
client = getResourceClient(t, ctx.Helper, ctx.ViewerUser, getLibraryElementGVR())
}
t.Run("library element viewing", func(t *testing.T) {
uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element for "+role+" to view", "", nil)
require.NoError(t, err)
viewedLibElement, err := client.Resource.Get(context.Background(), uid, v1.GetOptions{})
require.NoError(t, err, "All identities should be able to view library elements")
require.NotNil(t, viewedLibElement)
err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
require.NoError(t, err)
})
t.Run("library element listing", func(t *testing.T) {
uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element for "+role+" to list", "", nil)
require.NoError(t, err)
listOpts := v1.ListOptions{}
libElementList, err := client.Resource.List(context.Background(), listOpts)
require.NoError(t, err, "All identities should be able to list library elements")
require.NotNil(t, libElementList)
require.Len(t, libElementList.Items, 1)
require.Equal(t, uid, libElementList.Items[0].GetName())
err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
require.NoError(t, err)
})
t.Run("library element in a folder only an admin can see", func(t *testing.T) {
restrictedFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "Restricted Folder for "+role)
require.NoError(t, err)
require.NotNil(t, restrictedFolder)
uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element in restricted folder", restrictedFolder.UID, nil)
require.NoError(t, err)
setResourceUserPermission(t, ctx, ctx.AdminUser, false, restrictedFolder.UID, []ResourcePermissionSetting{})
if role == "Admin" {
_, err = client.Resource.Get(context.Background(), uid, v1.GetOptions{})
require.NoError(t, err, "Admin should be able to access library element in restricted folder")
} else {
_, err = client.Resource.Get(context.Background(), uid, v1.GetOptions{})
require.Error(t, err, "Should not be able to access library element in restricted folder")
}
err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
require.NoError(t, err)
})
})
}
}
func runLibraryElementCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) {
// org1 trying to access org2
org1CrossEditorClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
User: org1Ctx.EditorUser,
Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID),
GVR: getLibraryElementGVR(),
})
org1CrossViewerClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
User: org1Ctx.ViewerUser,
Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID),
GVR: getLibraryElementGVR(),
})
// org2 trying to access org1
org2CrossEditorClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
User: org2Ctx.EditorUser,
Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID),
GVR: getLibraryElementGVR(),
})
org2CrossViewerClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
User: org2Ctx.ViewerUser,
Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID),
GVR: getLibraryElementGVR(),
})
t.Run("Cross-organization access", func(t *testing.T) {
org1LibElementUID, err := createLibraryElement(t, org1Ctx, org1Ctx.AdminUser, "Org1 Library Element", "", nil)
require.NoError(t, err)
org2LibElementUID, err := createLibraryElement(t, org2Ctx, org2Ctx.AdminUser, "Org2 Library Element", "", nil)
require.NoError(t, err)
defer func() {
err = deleteLibraryElement(t, org1Ctx, org1Ctx.AdminUser, org1LibElementUID)
require.NoError(t, err)
err = deleteLibraryElement(t, org2Ctx, org2Ctx.AdminUser, org2LibElementUID)
require.NoError(t, err)
}()
testCrossOrgAccess := func(client *apis.K8sResourceClient, targetLibElementUID string, description string) {
t.Run(description, func(t *testing.T) {
_, err := client.Resource.Get(context.Background(), targetLibElementUID, v1.GetOptions{})
require.Error(t, err, "Should not be able to access library element from another org")
})
}
testCrossOrgAccess(org1CrossEditorClient, org2LibElementUID, "Org1 editor cannot access Org2 library element")
testCrossOrgAccess(org1CrossViewerClient, org2LibElementUID, "Org1 viewer cannot access Org2 library element")
testCrossOrgAccess(org2CrossEditorClient, org1LibElementUID, "Org2 editor cannot access Org1 library element")
testCrossOrgAccess(org2CrossViewerClient, org1LibElementUID, "Org2 viewer cannot access Org1 library element")
})
}
func getLibraryElementGVR() schema.GroupVersionResource {
return schema.GroupVersionResource{
Group: dashboardV0.APIGroup,
Version: dashboardV0.VERSION,
Resource: dashboardV0.LIBRARY_PANEL_RESOURCE,
}
}
// currently through /api
func createLibraryElement(t *testing.T, ctx TestContext, user apis.User, title string, folderUID string, uid *string) (string, error) {
t.Helper()
libraryElement := map[string]interface{}{
"kind": 1,
"name": title,
"model": map[string]interface{}{
"type": "text",
"title": title,
},
}
if folderUID != "" {
libraryElement["folderUid"] = folderUID
}
libraryElementURL := "/api/library-elements"
libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, user)
if err != nil {
return "", err
}
require.NotNil(t, libraryElementData)
data := libraryElementData["result"].(map[string]interface{})
uidStr := data["uid"].(string)
require.NotEmpty(t, uidStr)
return uidStr, nil
}
// currently through /api
func deleteLibraryElement(t *testing.T, ctx TestContext, user apis.User, uid string) error {
t.Helper()
deleteURL := fmt.Sprintf("/api/library-elements/%s", uid)
resp := apis.DoRequest(ctx.Helper, apis.RequestParams{
User: user,
Method: http.MethodDelete,
Path: deleteURL,
}, &struct{}{})
if resp.Response.StatusCode != http.StatusOK {
return fmt.Errorf("failed to delete library element: %s", resp.Response.Status)
}
return nil
}