Files
grafana/pkg/registry/apis/dashboard/register.go
T
2025-12-30 09:54:20 -03:00

1020 lines
36 KiB
Go

package dashboard
import (
"context"
"errors"
"fmt"
"maps"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/configprovider"
"github.com/prometheus/client_golang/prometheus"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/client-go/dynamic"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
manifestdata "github.com/grafana/grafana/apps/dashboard/pkg/apis"
internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/conversion"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"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/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/snapshot"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver"
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/live"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util"
)
var (
_ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupVersionsProvider = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupRouteProvider = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupMutation = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupValidation = (*DashboardsAPIBuilder)(nil)
)
const (
dashboardSpecTitle = "title"
dashboardSpecRefreshInterval = "refresh"
)
type simpleFolderClientProvider struct {
handler client.K8sHandler
}
func newSimpleFolderClientProvider(handler client.K8sHandler) client.K8sHandlerProvider {
return &simpleFolderClientProvider{handler: handler}
}
func (p *simpleFolderClientProvider) GetOrCreateHandler(namespace string) client.K8sHandler {
return p.handler
}
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboardService dashboards.DashboardService
features featuremgmt.FeatureToggles
accessControl accesscontrol.AccessControl
accessClient authlib.AccessClient
legacy *DashboardStorage
unified resource.ResourceClient
dashboardProvisioningService dashboards.DashboardProvisioningService
dashboardPermissions dashboards.PermissionsRegistrationService
dashboardPermissionsSvc accesscontrol.DashboardPermissionsService // TODO: once kubernetesAuthzResourcePermissionApis is enabled, rely solely on resourcePermissionsSvc and add integration test afterDelete hook
resourcePermissionsSvc *dynamic.NamespaceableResourceInterface
scheme *runtime.Scheme
search *SearchHandler
dashStore dashboards.Store
QuotaService quota.Service
ProvisioningService provisioning.ProvisioningService
minRefreshInterval string
dualWriter dualwrite.Service
folderClientProvider client.K8sHandlerProvider
libraryPanels libraryelements.Service // for legacy library panels
publicDashboardService publicdashboards.Service
snapshotService dashboardsnapshots.Service
snapshotOptions dashv0.SnapshotSharingOptions
namespacer request.NamespaceMapper
dashboardActivityChannel live.DashboardActivityChannel
isStandalone bool // skips any handling including anything to do with legacy storage
}
func RegisterAPIService(
features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
datasourceService datasources.DataSourceService,
dashboardPermissions dashboards.PermissionsRegistrationService,
dashboardPermissionsSvc accesscontrol.DashboardPermissionsService,
accessControl accesscontrol.AccessControl,
accessClient authlib.AccessClient,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
reg prometheus.Registerer,
sql db.DB,
tracing *tracing.TracingService,
unified resource.ResourceClient,
dual dualwrite.Service,
sorter sort.Service,
quotaService quota.Service,
libraryPanelSvc librarypanels.Service,
restConfigProvider apiserver.RestConfigProvider,
userService user.Service,
libraryPanels libraryelements.Service,
publicDashboardService publicdashboards.Service,
snapshotService dashboardsnapshots.Service,
dashboardActivityChannel live.DashboardActivityChannel,
configProvider configprovider.ConfigProvider,
) *DashboardsAPIBuilder {
cfg, err := configProvider.Get(context.Background())
if err != nil {
logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err)
return nil
}
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
folderClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), folders.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashStore, userService, unified, sorter, features)
snapshotOptions := dashv0.SnapshotSharingOptions{
SnapshotsEnabled: cfg.SnapshotEnabled,
ExternalSnapshotURL: cfg.ExternalSnapshotUrl,
ExternalSnapshotName: cfg.ExternalSnapshotName,
ExternalEnabled: cfg.ExternalEnabled,
}
builder := &DashboardsAPIBuilder{
dashboardService: dashboardService,
dashboardPermissions: dashboardPermissions,
dashboardPermissionsSvc: dashboardPermissionsSvc,
features: features,
accessControl: accessControl,
accessClient: accessClient,
unified: unified,
dashboardProvisioningService: provisioningDashboardService,
search: NewSearchHandler(tracing, dual, legacyDashboardSearcher, unified, features),
dashStore: dashStore,
QuotaService: quotaService,
ProvisioningService: provisioning,
minRefreshInterval: cfg.MinRefreshInterval,
dualWriter: dual,
folderClientProvider: newSimpleFolderClientProvider(folderClient),
libraryPanels: libraryPanels,
publicDashboardService: publicDashboardService,
snapshotService: snapshotService,
snapshotOptions: snapshotOptions,
namespacer: namespacer,
dashboardActivityChannel: dashboardActivityChannel,
legacy: &DashboardStorage{
Access: legacy.NewDashboardSQLAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
DashboardService: dashboardService,
},
}
migration.RegisterMetrics(reg)
migration.Initialize(&datasourceIndexProvider{
datasourceService: datasourceService,
}, &libraryElementIndexProvider{
libraryElementService: libraryPanels,
}, cfg.DashboardSchemaMigrationCacheTTL)
// For single-tenant deployments (indicated by StackID), preload the cache in the background
if cfg.StackID != "" {
// Single namespace for cloud stack
stackID, err := strconv.ParseInt(cfg.StackID, 10, 64)
if err == nil {
var nsInfo authlib.NamespaceInfo
nsInfo, err = authlib.ParseNamespace(authlib.CloudNamespaceFormatter(stackID))
if err == nil {
migration.PreloadCacheInBackground([]authlib.NamespaceInfo{nsInfo})
}
}
if err != nil {
logging.DefaultLogger.Error("failed to parse namespace for cache preloading", "stackId", cfg.StackID, "err", err)
}
}
apiregistration.RegisterAPI(builder)
return builder
}
func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceIndexProvider, libraryElementProvider schemaversion.LibraryElementIndexProvider, resourcePermissionsSvc *dynamic.NamespaceableResourceInterface, search *SearchHandler) *DashboardsAPIBuilder {
migration.Initialize(datasourceProvider, libraryElementProvider, migration.DefaultCacheTTL)
return &DashboardsAPIBuilder{
minRefreshInterval: "10s",
accessClient: ac,
features: features,
dashboardService: &dashsvc.DashboardServiceImpl{}, // for validation helpers only
folderClientProvider: folderClientProvider,
resourcePermissionsSvc: resourcePermissionsSvc,
search: search,
isStandalone: true,
}
}
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
return []schema.GroupVersion{
dashv2beta1.DashboardResourceInfo.GroupVersion(),
dashv2alpha1.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv1.DashboardResourceInfo.GroupVersion(),
}
}
return []schema.GroupVersion{
dashv1.DashboardResourceInfo.GroupVersion(),
dashv0.DashboardResourceInfo.GroupVersion(),
dashv2beta1.DashboardResourceInfo.GroupVersion(),
dashv2alpha1.DashboardResourceInfo.GroupVersion(),
}
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
b.scheme = scheme
if err := dashv0.AddToScheme(scheme); err != nil {
return err
}
if err := dashv1.AddToScheme(scheme); err != nil {
return err
}
if err := dashv2alpha1.AddToScheme(scheme); err != nil {
return err
}
if err := dashv2beta1.AddToScheme(scheme); err != nil {
return err
}
// Register the explicit conversions
if err := conversion.RegisterConversions(scheme, migration.GetDataSourceIndexProvider(), migration.GetLibraryElementIndexProvider()); err != nil {
return err
}
return scheme.SetVersionPriority(b.GetGroupVersions()...)
}
func (b *DashboardsAPIBuilder) AllowedV0Alpha1Resources() []string {
return []string{
dashv0.DashboardKind().Plural(),
dashv0.LIBRARY_PANEL_RESOURCE,
dashv0.SNAPSHOT_RESOURCE,
}
}
// Validate validates dashboard operations for the apiserver
func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
switch a.GetResource().Resource {
case dashv0.DASHBOARD_RESOURCE:
// Handle different operations
switch op {
case admission.Delete:
return b.validateDelete(ctx, a)
case admission.Create:
return b.validateCreate(ctx, a, o)
case admission.Update:
return b.validateUpdate(ctx, a, o)
case admission.Connect:
return nil
}
case dashv0.LIBRARY_PANEL_RESOURCE:
return nil // OK for now
case dashv0.SNAPSHOT_RESOURCE:
return nil // OK for now
}
return fmt.Errorf("unsupported validation: %+v", a.GetResource())
}
// validateDelete checks if a dashboard can be deleted
func (b *DashboardsAPIBuilder) validateDelete(ctx context.Context, a admission.Attributes) error {
obj := a.GetOperationOptions()
deleteOptions, ok := obj.(*metav1.DeleteOptions)
if !ok {
return fmt.Errorf("expected v1.DeleteOptions")
}
// Skip validation for forced deletions (grace period = 0)
if deleteOptions.GracePeriodSeconds != nil && *deleteOptions.GracePeriodSeconds == 0 {
return nil
}
nsInfo, err := authlib.ParseNamespace(a.GetNamespace())
if err != nil {
return fmt.Errorf("%v: %w", "failed to parse namespace", err)
}
// HACK: deletion validation currently doesn't work for the standalone case. So we currently skip it.
if b.isStandalone && util.IsInterfaceNil(b.dashboardProvisioningService) {
return nil
}
// The name of the resource is the dashboard UID
dashboardUID := a.GetName()
provisioningData, err := b.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, dashboardUID)
if err != nil {
if errors.Is(err, dashboards.ErrProvisionedDashboardNotFound) ||
errors.Is(err, dashboards.ErrDashboardNotFound) ||
apierrors.IsNotFound(err) {
return nil
}
return fmt.Errorf("%v: %w", "delete hook failed to check if dashboard is provisioned", err)
}
if provisioningData != nil {
return apierrors.NewBadRequest(dashboards.ErrDashboardCannotDeleteProvisionedDashboard.Reason)
}
return nil
}
// validateCreate validates dashboard creation
func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
// Get the dashboard object
dashObj := a.GetObject()
title, refresh, err := getDashboardProperties(dashObj)
if err != nil {
return fmt.Errorf("error extracting dashboard properties: %w", err)
}
accessor, err := utils.MetaAccessor(dashObj)
if err != nil {
return fmt.Errorf("error getting meta accessor: %w", err)
}
// Basic validations
if err := b.dashboardService.ValidateBasicDashboardProperties(title, accessor.GetName(), accessor.GetMessage()); err != nil {
return apierrors.NewBadRequest(err.Error())
}
// Validate refresh interval
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.minRefreshInterval, refresh); err != nil {
return apierrors.NewBadRequest(err.Error())
}
id, err := identity.GetRequester(ctx)
if err != nil {
return fmt.Errorf("error getting requester: %w", err)
}
// Validate folder existence if specified
if !a.IsDryRun() && accessor.GetFolder() != "" {
folder, err := b.validateFolderExists(ctx, accessor.GetFolder(), id.GetOrgID())
if err != nil {
return err
}
if err := b.validateFolderManagedBySameManager(folder, accessor); err != nil {
return apierrors.NewBadRequest(err.Error())
}
}
// Validate quota
if !b.isStandalone && !a.IsDryRun() {
params := &quota.ScopeParameters{}
params.OrgID = id.GetOrgID()
internalId, err := id.GetInternalID()
if err == nil {
params.UserID = internalId
}
quotaReached, err := b.QuotaService.CheckQuotaReached(ctx, dashboards.QuotaTargetSrv, params)
if err != nil && !errors.Is(err, quota.ErrDisabled) {
return err
}
if quotaReached {
return apierrors.NewForbidden(dashv1.DashboardResourceInfo.GroupResource(), a.GetName(), dashboards.ErrQuotaReached)
}
}
return nil
}
// validateUpdate validates dashboard updates
func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
// Get the new and old dashboards
newDashObj := a.GetObject()
oldDashObj := a.GetOldObject()
title, refresh, err := getDashboardProperties(newDashObj)
if err != nil {
return fmt.Errorf("error extracting dashboard properties: %w", err)
}
oldAccessor, err := utils.MetaAccessor(oldDashObj)
if err != nil {
return fmt.Errorf("error getting old dash meta accessor: %w", err)
}
newAccessor, err := utils.MetaAccessor(newDashObj)
if err != nil {
return fmt.Errorf("error getting new dash meta accessor: %w", err)
}
// Parse namespace for old dashboard
nsInfo, err := authlib.ParseNamespace(oldAccessor.GetNamespace())
if err != nil {
return fmt.Errorf("failed to parse namespace: %w", err)
}
// Basic validations
if err := b.dashboardService.ValidateBasicDashboardProperties(title, newAccessor.GetName(), newAccessor.GetMessage()); err != nil {
return apierrors.NewBadRequest(err.Error())
}
// Validate folder existence if specified and changed
if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" {
id, err := identity.GetRequester(ctx)
if err != nil {
return fmt.Errorf("error getting requester: %w", err)
}
if err := b.verifyFolderAccessPermissions(ctx, id, newAccessor.GetFolder()); err != nil {
return err
}
folder, err := b.validateFolderExists(ctx, newAccessor.GetFolder(), nsInfo.OrgID)
if err != nil {
return apierrors.NewNotFound(folders.FolderResourceInfo.GroupResource(), newAccessor.GetFolder())
}
if err := b.validateFolderManagedBySameManager(folder, newAccessor); err != nil {
return err
}
}
// Validate refresh interval
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.minRefreshInterval, refresh); err != nil {
return apierrors.NewBadRequest(err.Error())
}
return nil
}
// validateFolderExists checks if a folder exists
func (b *DashboardsAPIBuilder) validateFolderExists(ctx context.Context, folderUID string, orgID int64) (*unstructured.Unstructured, error) {
ns, err := request.NamespaceInfoFrom(ctx, false)
if err != nil {
return nil, err
}
folderClient := b.folderClientProvider.GetOrCreateHandler(ns.Value)
folder, err := folderClient.Get(ctx, folderUID, orgID, metav1.GetOptions{})
// Check if the error is a context deadline exceeded error
if err != nil {
// historically, we returned a more verbose error with folder name when its not found, below just keeps that behavior
if apierrors.IsNotFound(err) {
return nil, apierrors.NewNotFound(folders.FolderResourceInfo.GroupResource(), folderUID)
}
return nil, err
}
return folder, nil
}
// validation should fail if:
// 1. The parent folder is managed but this dashboard is not
// 2. The parent folder is managed by a different repository than this dashboard
func (b *DashboardsAPIBuilder) validateFolderManagedBySameManager(folder *unstructured.Unstructured, dashboardAccessor utils.GrafanaMetaAccessor) error {
folderAccessor, err := utils.MetaAccessor(folder)
if err != nil {
return fmt.Errorf("error getting meta accessor: %w", err)
}
if folderManager, ok := folderAccessor.GetManagerProperties(); ok && folderManager.Kind == utils.ManagerKindRepo {
manager, ok := dashboardAccessor.GetManagerProperties()
if !ok {
return fmt.Errorf("folder is managed by a repository, but the dashboard is not managed")
}
if manager.Kind != utils.ManagerKindRepo || manager.Identity != folderManager.Identity {
return fmt.Errorf("folder is managed by a repository, but the dashboard is not managed by the same manager")
}
}
return nil
}
// getDashboardProperties extracts title and refresh interval from any dashboard version
func getDashboardProperties(obj runtime.Object) (string, string, error) {
var title, refresh string
// Extract properties based on the object's type
switch d := obj.(type) {
case *dashv0.Dashboard:
title = d.Spec.GetNestedString(dashboardSpecTitle)
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
case *dashv1.Dashboard:
title = d.Spec.GetNestedString(dashboardSpecTitle)
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
case *dashv2alpha1.Dashboard:
title = d.Spec.Title
refresh = d.Spec.TimeSettings.AutoRefresh
case *dashv2beta1.Dashboard:
title = d.Spec.Title
refresh = d.Spec.TimeSettings.AutoRefresh
default:
return "", "", fmt.Errorf("unsupported dashboard version: %T", obj)
}
return title, refresh, nil
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storageOpts := apistore.StorageOptions{
EnableFolderSupport: true,
RequireDeprecatedInternalID: true,
}
if b.isStandalone {
storageOpts.Permissions = b.setDefaultDashboardPermissions
} else {
storageOpts.Permissions = b.dashboardPermissions.SetDefaultPermissionsAfterCreate
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
//nolint:staticcheck // not yet migrated to OpenFeature
if b.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = NewDashboardLargeObjectSupport(opts.Scheme, opts.StorageOpts.BlobThresholdBytes)
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptsRegister(dashv0.DashboardResourceInfo.GroupResource(), storageOpts)
// v0alpha1
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
dashv0.DashboardResourceInfo,
&dashv0.LibraryPanelResourceInfo,
&dashv0.SnapshotResourceInfo,
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &dashv0.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv0.Dashboard)
if ok {
dto.Dashboard = *dash
}
if access != nil {
err = b.scheme.Convert(access, &dto.Access, nil)
}
return dto, err
}); err != nil {
return err
}
// v1alpha1
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
dashv1.DashboardResourceInfo,
nil, // do not register library panel
nil,
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &dashv1.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv1.Dashboard)
if ok {
dto.Dashboard = *dash
}
if access != nil {
err = b.scheme.Convert(access, &dto.Access, nil)
}
return dto, err
}); err != nil {
return err
}
// v2alpha1
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
dashv2alpha1.DashboardResourceInfo,
nil, // do not register library panel
nil,
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &dashv2alpha1.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv2alpha1.Dashboard)
if ok {
dto.Dashboard = *dash
}
if access != nil {
err = b.scheme.Convert(access, &dto.Access, nil)
}
return dto, err
}); err != nil {
return err
}
if err := b.storageForVersion(apiGroupInfo, opts, largeObjects,
dashv2beta1.DashboardResourceInfo,
nil, // do not register library panel
nil,
func(obj runtime.Object, access *internal.DashboardAccess) (v runtime.Object, err error) {
dto := &dashv2beta1.DashboardWithAccessInfo{}
dash, ok := obj.(*dashv2beta1.Dashboard)
if ok {
dto.Dashboard = *dash
}
if access != nil {
err = b.scheme.Convert(access, &dto.Access, nil)
}
return dto, err
}); err != nil {
return err
}
return nil
}
func (b *DashboardsAPIBuilder) storageForVersion(
apiGroupInfo *genericapiserver.APIGroupInfo,
opts builder.APIGroupOptions,
largeObjects apistore.LargeObjectSupport,
dashboards utils.ResourceInfo,
libraryPanels *utils.ResourceInfo,
snapshots *utils.ResourceInfo,
newDTOFunc dtoBuilder,
) error {
// Register the versioned storage
storage := map[string]rest.Storage{}
apiGroupInfo.VersionedResourcesStorageMap[dashboards.GroupVersion().Version] = storage
if b.isStandalone {
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, dashboards, opts.OptsGetter)
if err != nil {
return err
}
unified.AfterDelete = b.afterDelete
storage[dashboards.StoragePath()] = unified
storage[dashboards.StoragePath("dto")], err = NewDTOConnector(
unified,
largeObjects,
b.unified,
b.accessClient,
newDTOFunc,
nil, // no publicDashboardService in standalone mode
)
if err != nil {
return err
}
return nil
}
legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, opts.MetricsRegister, b.dashboardPermissions, b.accessClient)
if err != nil {
return err
}
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, dashboards, opts.OptsGetter)
if err != nil {
return err
}
unified.AfterDelete = b.afterDelete
gr := dashboards.GroupResource()
dw, err := opts.DualWriteBuilder(gr, legacyStore, unified)
if err != nil {
return err
}
storage[dashboards.StoragePath()] = dashboardStorageWrapper{
Storage: dw,
dashboardPermissionsSvc: b.dashboardPermissionsSvc,
live: b.dashboardActivityChannel,
}
// Register the DTO endpoint that will consolidate all dashboard bits
storage[dashboards.StoragePath("dto")], err = NewDTOConnector(
storage[dashboards.StoragePath()].(rest.Getter),
largeObjects,
b.unified,
b.accessClient,
newDTOFunc,
b.publicDashboardService,
)
if err != nil {
return err
}
// Expose read library panels
//nolint:staticcheck // not yet migrated to OpenFeature
if libraryPanels != nil && b.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
legacyLibraryStore := &LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
service: b.libraryPanels,
}
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
}
}
// Legacy only (for now) and only v0alpha1
if snapshots != nil && dashboards.GroupVersion().Version == "v0alpha1" {
snapshotLegacyStore := &snapshot.SnapshotLegacyStore{
ResourceInfo: *snapshots,
Service: b.snapshotService,
Namespacer: b.namespacer,
}
storage[snapshots.StoragePath()] = snapshotLegacyStore
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
if err != nil {
return err
}
}
return nil
}
func (b *DashboardsAPIBuilder) afterDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if util.IsInterfaceNil(b.resourcePermissionsSvc) {
return
}
ctx := context.Background()
log := logging.DefaultLogger
meta, err := utils.MetaAccessor(obj)
if err != nil {
log.Error("Failed to access deleted dashboard object metadata", "error", err)
return
}
log.Debug("deleting dashboard permissions", "uid", meta.GetName(), "namespace", meta.GetNamespace())
client := (*b.resourcePermissionsSvc).Namespace(meta.GetNamespace())
name := fmt.Sprintf("%s-%s-%s", dashv1.DashboardResourceInfo.GroupVersionResource().Group, dashv1.DashboardResourceInfo.GroupVersionResource().Resource, meta.GetName())
err = client.Delete(ctx, name, metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
log.Error("failed to delete dashboard permissions", "error", err)
}
}
var defaultDashboardPermissions = []map[string]any{
{
"kind": "BasicRole",
"name": "Editor",
"verb": "edit",
},
{
"kind": "BasicRole",
"name": "Viewer",
"verb": "view",
},
}
func (b *DashboardsAPIBuilder) setDefaultDashboardPermissions(ctx context.Context, key *resourcepb.ResourceKey, id authlib.AuthInfo, obj utils.GrafanaMetaAccessor) error {
if b.resourcePermissionsSvc == nil {
return nil
}
if obj.GetFolder() != "" {
return nil
}
log := logging.FromContext(ctx)
log.Debug("setting default dashboard permissions", "uid", obj.GetName(), "namespace", obj.GetNamespace())
client := (*b.resourcePermissionsSvc).Namespace(obj.GetNamespace())
name := fmt.Sprintf("%s-%s-%s", dashv1.DashboardResourceInfo.GroupVersionResource().Group, dashv1.DashboardResourceInfo.GroupVersionResource().Resource, obj.GetName())
if _, err := client.Get(ctx, name, metav1.GetOptions{}); err == nil {
_, err := client.Update(ctx, &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]any{
"name": name,
"namespace": obj.GetNamespace(),
},
"spec": map[string]any{
"resource": map[string]any{
"apiGroup": dashv1.DashboardResourceInfo.GroupVersionResource().Group,
"resource": dashv1.DashboardResourceInfo.GroupVersionResource().Resource,
"name": obj.GetName(),
},
"permissions": defaultDashboardPermissions,
},
},
}, metav1.UpdateOptions{})
if err != nil {
log.Error("failed to update dashboard permissions", "error", err)
return fmt.Errorf("update dashboard permissions: %w", err)
}
return nil
}
_, err := client.Create(ctx, &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]any{
"name": name,
"namespace": obj.GetNamespace(),
},
"spec": map[string]any{
"resource": map[string]any{
"apiGroup": dashv1.DashboardResourceInfo.GroupVersionResource().Group,
"resource": dashv1.DashboardResourceInfo.GroupVersionResource().Resource,
"name": obj.GetName(),
},
"permissions": defaultDashboardPermissions,
},
},
}, metav1.CreateOptions{})
if err != nil {
log.Error("failed to create dashboard permissions", "error", err)
return fmt.Errorf("create dashboard permissions: %w", err)
}
return nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
defs := dashv0.GetOpenAPIDefinitions(ref)
maps.Copy(defs, dashv1.GetOpenAPIDefinitions(ref))
maps.Copy(defs, dashv2alpha1.GetOpenAPIDefinitions(ref))
maps.Copy(defs, dashv2beta1.GetOpenAPIDefinitions(ref))
md := manifestdata.LocalManifest().ManifestData
// Overwrite the OpenAPI generated from kubernetes (sourced from the go types) with the OpenAPI generated by grafana-app-sdk
// from the manifest CUE, as it correctly handles the CUE disjunctions in the dashboard spec.
// We don't touch any types which were not specified in the manifest CUE (such as custom route types).
for _, version := range md.Versions {
// We don't need to correct the v0 or v1 openAPI as the spec type is just `any`
if len(version.Name) > 1 && (version.Name[1] == '0' || version.Name[1] == '1') {
continue
}
for _, kind := range version.Kinds {
pkgPrefix := fmt.Sprintf("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/%s", version.Name)
oapi, err := kind.Schema.AsKubeOpenAPI(schema.GroupVersionKind{
Group: md.Group,
Version: version.Name,
Kind: kind.Kind,
}, ref, pkgPrefix)
if err != nil {
logging.DefaultLogger.Error("unable to generate openAPI for kind %s: %w", kind.Kind, err)
continue
}
maps.Copy(defs, oapi)
}
}
// Fix legacyOptions schema for v2alpha1 and v2beta1 to allow any value type
// The generated schema incorrectly restricts values to objects, but map[string]interface{} can hold any type
// This fix must be applied here so structured-merge-diff uses the correct schema
// For some reason this issue occurs with both the kubernetes-generated openAPI sourced from go, _and_ the OpenAPI from the AppManifest
// TODO: @IfSentient this should really be addressed in the app-sdk's generation, or work out what about this particular CUE value is broken
for _, defKey := range []string{
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQuerySpec",
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQuerySpec",
} {
if def, ok := defs[defKey]; ok {
if legacyOptions, ok := def.Schema.Properties["legacyOptions"]; ok {
// Fix: Use additionalProperties: true to allow any value type (string, number, boolean, array, object, etc.)
// instead of restricting to objects only. This must match map[string]interface{} semantics.
legacyOptions.AdditionalProperties = &spec.SchemaOrBool{Allows: true}
def.Schema.Properties["legacyOptions"] = legacyOptions
defs[defKey] = def
}
}
}
return defs
}
}
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
oas.Info.Description = "Grafana dashboards as resources"
// Add dashboard hits manually
if oas.Info.Title == "dashboard.grafana.app/v0alpha1" {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
defsBase := "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1."
refsBase := "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v0alpha1."
kinds := []string{"SearchResults", "DashboardHit", "ManagedBy", "FacetResult", "TermFacet", "SortBy"}
// Add any missing definitions
//-----------------------------
for _, k := range kinds {
v := defs[defsBase+k]
clean := strings.Replace(k, defsBase, refsBase, 1)
if oas.Components.Schemas[clean] == nil {
switch k {
case "SearchResults":
v.Schema.Properties["sortBy"] = *spec.RefProperty(
"#/components/schemas/SortBy")
v.Schema.Properties["hits"] = *spec.ArrayProperty(
spec.RefProperty("#/components/schemas/DashboardHit"),
)
v.Schema.Properties["facets"] = *spec.MapProperty(
spec.RefProperty("#/components/schemas/FacetResult"),
)
case "DashboardHit":
v.Schema.Properties["managedBy"] = *spec.RefProperty(
"#/components/schemas/ManagedBy")
case "FacetResult":
v.Schema.Properties["terms"] = *spec.ArrayProperty(
spec.RefProperty("#/components/schemas/TermFacet"),
)
}
oas.Components.Schemas[clean] = &v.Schema
}
}
p := oas.Paths.Paths["/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search"]
p.Get.Responses.StatusCodeResponses[200] = &spec3.Response{
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: spec.RefSchema("#/components/schemas/SearchResults"),
},
},
},
},
}
}
return oas, nil
}
func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
if gv.Version != dashv0.VERSION {
return nil // Only show the custom routes for v0
}
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
searchAPIRoutes := b.search.GetAPIRoutes(defs)
snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs)
return &builder.APIRoutes{
Namespace: append(searchAPIRoutes.Namespace, snapshotAPIRoutes.Namespace...),
}
}
// The default authorizer is fine because authorization happens in storage where we know the parent folder
func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return grafanaauthorizer.NewServiceAuthorizer()
}
func (b *DashboardsAPIBuilder) verifyFolderAccessPermissions(ctx context.Context, user identity.Requester, folderIds ...string) error {
ns, err := request.NamespaceInfoFrom(ctx, false)
if err != nil {
return err
}
folderClient := b.folderClientProvider.GetOrCreateHandler(ns.Value)
for _, folderId := range folderIds {
resp, err := folderClient.Get(ctx, folderId, ns.OrgID, metav1.GetOptions{}, "access")
if err != nil {
return dashboards.ErrFolderAccessDenied
}
var accessInfo folders.FolderAccessInfo
err = runtime.DefaultUnstructuredConverter.FromUnstructured(resp.Object, &accessInfo)
if err != nil {
logging.FromContext(ctx).Error("Failed to convert folder access response", "error", err)
return dashboards.ErrFolderAccessDenied
}
if !accessInfo.CanEdit {
return dashboards.ErrFolderAccessDenied
}
}
return nil
}