Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Marbach c048b3ff6f remove unnecessary axis show stuff 2025-12-15 10:05:46 -05:00
Paul Marbach 65249a12ae Utils: Default show axis to false when AxisPlacement is Hidden 2025-12-15 09:58:56 -05:00
11 changed files with 104 additions and 405 deletions
@@ -21,28 +21,11 @@ weight: 120
# Install a plugin
{{< admonition type="note" >}}
Installing plugins from the Grafana website into a Grafana Cloud instance will be removed in February 2026.
If you're a Grafana Cloud user, follow [Install a plugin through the Grafana UI](#install-a-plugin-through-the-grafana-uiinstall-a-plugin-through-the-grafana-ui) instead.
{{< /admonition >}}
## Install a plugin through the Grafana UI
The most common way to install a plugin is through the Grafana UI.
1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view all plugins.
1. Browse and find a plugin.
1. Click the plugin's logo.
1. Click **Install**.
You can use use the following alternative methods to install a plugin depending on your environment or setup.
Besides the UI, you can use alternative methods to install a plugin depending on your environment or set-up.
## Install a plugin using Grafana CLI
The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/cli/#plugins-commands).
The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](/docs/grafana/<GRAFANA_VERSION>/cli/#plugins-commands).
## Install a plugin from a ZIP file
@@ -24,7 +24,7 @@ Before you begin, you should have the following available:
- Administrator permissions in your Grafana instance; for more information on assigning Grafana RBAC roles, refer to [Assign RBAC roles](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/assign-rbac-roles/).
{{< admonition type="note" >}}
Save all of the following Terraform configuration files in the same directory.
All of the following Terraform configuration files should be saved in the same directory.
{{< /admonition >}}
## Configure the Grafana provider
@@ -70,8 +70,8 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
let {
scaleKey,
label,
show = true,
placement = AxisPlacement.Auto,
show = placement !== AxisPlacement.Hidden,
grid = { show: true },
ticks,
space,
@@ -108,7 +108,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
const xFieldAxisPlacement =
xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden;
const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden;
if (xField.type === FieldType.time) {
builder.addScale({
@@ -136,7 +135,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
scaleKey: xScaleKey,
isTime: true,
placement: xFieldAxisPlacement,
show: xFieldAxisShow,
label: xField.config.custom?.axisLabel,
timeZone,
theme,
@@ -178,7 +176,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
builder.addAxis({
scaleKey: xScaleKey,
placement: xFieldAxisPlacement,
show: xFieldAxisShow,
label: xField.config.custom?.axisLabel,
theme,
grid: { show: xField.config.custom?.axisGridShow },
+1 -2
View File
@@ -105,8 +105,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
return
}
folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree())
authorizer := resources.NewRepositoryAuthorizer(repo.Config(), c.access)
dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, authorizer)
dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access)
query := r.URL.Query()
opts := resources.DualWriteOptions{
Ref: query.Get("ref"),
@@ -7,9 +7,9 @@ import (
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/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
@@ -21,11 +21,18 @@ import (
// DualReadWriter is a wrapper around a repository that can read from and write resources
// into both the Git repository as well as in Grafana. It isn't a dual writer in the sense of what unistore handling calls dual writing.
// Standard provisioning Authorizer has already run by the time DualReadWriter is called
// for incoming requests from actors, external or internal. However, since it is the files
// connector that redirects here, the external resources such as dashboards
// end up requiring additional authorization checks which the DualReadWriter performs here.
// TODO: it does not support folders yet
type DualReadWriter struct {
repo repository.ReaderWriter
parser Parser
folders *FolderManager
authorizer Authorizer
repo repository.ReaderWriter
parser Parser
folders *FolderManager
access authlib.AccessChecker
}
type DualWriteOptions struct {
@@ -41,8 +48,8 @@ type DualWriteOptions struct {
Branch string // Configured default branch
}
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, authorizer Authorizer) *DualReadWriter {
return &DualReadWriter{repo: repo, parser: parser, folders: folders, authorizer: authorizer}
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access authlib.AccessChecker) *DualReadWriter {
return &DualReadWriter{repo: repo, parser: parser, folders: folders, access: access}
}
func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*ParsedResource, error) {
@@ -70,7 +77,8 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa
return nil, fmt.Errorf("error running dryRun: %w", err)
}
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbGet); err != nil {
// Authorize based on the existing resource
if err = r.authorize(ctx, parsed, utils.VerbGet); err != nil {
return nil, err
}
@@ -78,7 +86,7 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa
}
func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -104,7 +112,7 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return nil, fmt.Errorf("parse file: %w", err)
}
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbDelete); err != nil {
if err = r.authorize(ctx, parsed, utils.VerbDelete); err != nil {
return nil, err
}
@@ -136,7 +144,7 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
// CreateFolder creates a new folder in the repository
// FIXME: fix signature to return ParsedResource
func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions) (*provisioning.ResourceWrapper, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -144,12 +152,9 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions
return nil, fmt.Errorf("not a folder path")
}
// For create operations, use empty name to check parent folder permissions
folderParsed := folderParsedResource(opts.Path, opts.Ref, r.repo.Config(), "")
if err := r.authorizer.AuthorizeResource(ctx, folderParsed, utils.VerbCreate); err != nil {
if err := r.authorizeCreateFolder(ctx, opts.Path); err != nil {
return nil, err
}
// TODO: authorized to create folders under first existing ancestor folder
// Now actually create the folder
if err := r.repo.Create(ctx, opts.Path, opts.Ref, nil, opts.Message); err != nil {
@@ -197,7 +202,17 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions
// CreateResource creates a new resource in the repository
func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
return r.createOrUpdate(ctx, true, opts)
}
// UpdateResource updates a resource in the repository
func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
return r.createOrUpdate(ctx, false, opts)
}
// Create or updates a resource in the repository
func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts DualWriteOptions) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -212,8 +227,6 @@ func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptio
return nil, err
}
// TODO: check if the resource does not exist in the database.
// Make sure the value is valid
if !opts.SkipDryRun {
if err := parsed.DryRun(ctx); err != nil {
@@ -229,96 +242,12 @@ func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptio
return nil, fmt.Errorf("errors while parsing file [%v]", parsed.Errors)
}
// TODO: is this the right way?
// Check if resource already exists - create should fail if it does
if err = r.ensureExisting(ctx, parsed); err != nil {
return nil, err
// Verify that we can create (or update) the referenced resource
verb := utils.VerbUpdate
if parsed.Action == provisioning.ResourceActionCreate {
verb = utils.VerbCreate
}
if parsed.Existing != nil {
return nil, apierrors.NewConflict(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
fmt.Errorf("resource already exists"))
}
// Authorization check: Check if we can create the resource in the folder from the file
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbCreate); err != nil {
return nil, err
}
// TODO: authorized to create folders under first existing ancestor folder
data, err := parsed.ToSaveBytes()
if err != nil {
return nil, err
}
// Always use the provisioning identity when writing
ctx, _, err = identity.WithProvisioningIdentity(ctx, parsed.Obj.GetNamespace())
if err != nil {
return nil, fmt.Errorf("unable to use provisioning identity %w", err)
}
// TODO: handle the error repository.ErrFileAlreadyExists
err = r.repo.Create(ctx, opts.Path, opts.Ref, data, opts.Message)
if err != nil {
return nil, err // raw error is useful
}
// Directly update the grafana database
// Behaves the same running sync after writing
// FIXME: to make sure if behaves in the same way as in sync, we should
// we should refactor the code to use the same function.
if r.shouldUpdateGrafanaDB(opts, parsed) {
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure folder path exists: %w", err)
}
err = parsed.Run(ctx)
}
return parsed, err
}
// UpdateResource updates a resource in the repository
func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
return nil, err
}
info := &repository.FileInfo{
Data: opts.Data,
Path: opts.Path,
Ref: opts.Ref,
}
parsed, err := r.parser.Parse(ctx, info)
if err != nil {
return nil, err
}
// Make sure the value is valid
if !opts.SkipDryRun {
if err := parsed.DryRun(ctx); err != nil {
logger := logging.FromContext(ctx).With("path", opts.Path, "name", parsed.Obj.GetName(), "ref", opts.Ref)
logger.Warn("failed to dry run resource on update", "error", err)
return nil, fmt.Errorf("error running dryRun: %w", err)
}
}
if len(parsed.Errors) > 0 {
// Now returns BadRequest (400) for validation errors
return nil, fmt.Errorf("errors while parsing file [%v]", parsed.Errors)
}
// Populate existing resource to check permissions in the correct folder
if err = r.ensureExisting(ctx, parsed); err != nil {
return nil, err
}
// TODO: what to do with a name or kind change?
// Authorization check: Check if we can update the existing resource in its current folder
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbUpdate); err != nil {
if err = r.authorize(ctx, parsed, verb); err != nil {
return nil, err
}
@@ -333,7 +262,12 @@ func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptio
return nil, fmt.Errorf("unable to use provisioning identity %w", err)
}
err = r.repo.Update(ctx, opts.Path, opts.Ref, data, opts.Message)
// Create or update
if create {
err = r.repo.Create(ctx, opts.Path, opts.Ref, data, opts.Message)
} else {
err = r.repo.Update(ctx, opts.Path, opts.Ref, data, opts.Message)
}
if err != nil {
return nil, err // raw error is useful
}
@@ -355,7 +289,7 @@ func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptio
// MoveResource moves a resource from one path to another in the repository
func (r *DualReadWriter) MoveResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -394,19 +328,6 @@ func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOption
}
}
// Check permissions to delete the original folder
originalFolderID := ParseFolder(opts.OriginalPath, r.repo.Config().Name).ID
originalFolderParsed := folderParsedResource(opts.OriginalPath, opts.Ref, r.repo.Config(), originalFolderID)
if err := r.authorizer.AuthorizeResource(ctx, originalFolderParsed, utils.VerbDelete); err != nil {
return nil, fmt.Errorf("not authorized to move from original folder: %w", err)
}
// Check permissions to create at the new folder location (empty name for create)
newFolderParsed := folderParsedResource(opts.Path, opts.Ref, r.repo.Config(), "")
if err := r.authorizer.AuthorizeResource(ctx, newFolderParsed, utils.VerbCreate); err != nil {
return nil, fmt.Errorf("not authorized to move to new folder: %w", err)
}
// For branch operations, we just perform the repository move without updating Grafana DB
// Always use the provisioning identity when writing
ctx, _, err := identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
@@ -457,13 +378,8 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
return nil, fmt.Errorf("parse original file: %w", err)
}
// Populate existing resource to check delete permission in the correct folder
if err = r.ensureExisting(ctx, parsed); err != nil {
return nil, err
}
// Authorize delete on the original path (checks existing resource's folder if it exists)
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbDelete); err != nil {
// Authorize delete on the original path
if err = r.authorize(ctx, parsed, utils.VerbDelete); err != nil {
return nil, fmt.Errorf("not authorized to delete original file: %w", err)
}
@@ -501,20 +417,13 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
return nil, fmt.Errorf("errors while parsing moved file [%v]", newParsed.Errors)
}
// Populate existing resource at destination to check if we're overwriting something
if err = r.ensureExisting(ctx, newParsed); err != nil {
return nil, err
// Authorize create on the new path
verb := utils.VerbCreate
if newParsed.Action == provisioning.ResourceActionUpdate {
verb = utils.VerbUpdate
}
// Authorize for the target resource
// - If resource exists at destination: Check if we can update it in its folder
// - If no resource at destination: Check if we can create in the new folder
verb := utils.VerbUpdate
if newParsed.Existing == nil {
verb = utils.VerbCreate
}
if err = r.authorizer.AuthorizeResource(ctx, newParsed, verb); err != nil {
return nil, fmt.Errorf("not authorized for destination: %w", err)
if err = r.authorize(ctx, newParsed, verb); err != nil {
return nil, fmt.Errorf("not authorized to create new file: %w", err)
}
data, err := newParsed.ToSaveBytes()
@@ -572,25 +481,57 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
return newParsed, nil
}
// ensureExisting populates parsed.Existing if a resource with the given name exists in storage.
// Returns nil if no resource exists, if Client is nil, or if Existing is already populated.
// This is used before authorization checks to ensure we validate permissions against the actual
// existing resource's folder, not just the folder specified in the file.
func (r *DualReadWriter) ensureExisting(ctx context.Context, parsed *ParsedResource) error {
if parsed.Client == nil || parsed.Existing != nil {
return nil // Already populated or can't check
}
existing, err := parsed.Client.Get(ctx, parsed.Obj.GetName(), metav1.GetOptions{})
func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error {
id, err := identity.GetRequester(ctx)
if err != nil {
if apierrors.IsNotFound(err) {
return nil // No existing resource
}
return fmt.Errorf("failed to check for existing resource: %w", err)
return apierrors.NewUnauthorized(err.Error())
}
parsed.Existing = existing
return nil
var name string
if parsed.Existing != nil {
name = parsed.Existing.GetName()
} else {
name = parsed.Obj.GetName()
}
rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{
Group: parsed.GVR.Group,
Resource: parsed.GVR.Resource,
Namespace: id.GetNamespace(),
Name: name,
Verb: verb,
}, parsed.Meta.GetFolder())
if err != nil || !rsp.Allowed {
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
fmt.Errorf("no access to read the embedded file"))
}
idType, _, err := authlib.ParseTypeID(id.GetID())
if err != nil {
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), fmt.Errorf("could not determine identity type to check access"))
}
// only apply role based access if identity is not of type access policy
if idType == authlib.TypeAccessPolicy || id.GetOrgRole().Includes(identity.RoleEditor) {
return nil
}
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
fmt.Errorf("must be admin or editor to access files from provisioning"))
}
func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) error {
id, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized(err.Error())
}
// Simple role based access for now
if id.GetOrgRole().Includes(identity.RoleEditor) {
return nil
}
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
fmt.Errorf("must be admin or editor to access folders with provisioning"))
}
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
@@ -606,13 +547,6 @@ func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions
}
}
// Check permissions to delete the folder
folderID := ParseFolder(opts.Path, r.repo.Config().Name).ID
folderParsed := folderParsedResource(opts.Path, opts.Ref, r.repo.Config(), folderID)
if err := r.authorizer.AuthorizeResource(ctx, folderParsed, utils.VerbDelete); err != nil {
return nil, err
}
// For branch operations, just delete from the repository without updating Grafana DB
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
@@ -641,54 +575,6 @@ func getPathType(isDir bool) string {
return "file (no trailing '/')"
}
// folderParsedResource creates a ParsedResource for a folder path.
// This is used for authorization checks on folder operations.
// For create operations, name should be empty string to check parent permissions.
// For other operations, name should be the folder ID derived from the path.
func folderParsedResource(path, ref string, repo *provisioning.Repository, name string) *ParsedResource {
folderObj := &unstructured.Unstructured{}
folderObj.SetName(name)
folderObj.SetNamespace(repo.Namespace)
// TODO: which parent? top existing ancestor.
meta, _ := utils.MetaAccessor(folderObj)
if meta != nil {
// Set parent folder for folder operations
parentFolder := ""
if path != "" {
parentPath := safepath.Dir(path)
if parentPath != "" {
parentFolder = ParseFolder(parentPath, repo.Name).ID
} else {
parentFolder = RootFolder(repo)
}
}
meta.SetFolder(parentFolder)
}
return &ParsedResource{
Info: &repository.FileInfo{
Path: path,
Ref: ref,
},
Obj: folderObj,
Meta: meta,
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: repo.Spec.Type,
Namespace: repo.Namespace,
Name: repo.Name,
Title: repo.Spec.Title,
},
}
}
func folderDeleteResponse(ctx context.Context, path, ref string, repo repository.Repository) (*ParsedResource, error) {
urls, err := getFolderURLs(ctx, path, ref, repo)
if err != nil {
-158
View File
@@ -10,7 +10,6 @@ import (
"sync"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/assert"
@@ -591,160 +590,3 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
require.Equal(t, repo2, dashboard2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo2's dashboard should still be owned by repo2")
})
}
// TestIntegrationProvisioning_FilesAuthorization verifies that authorization
// works correctly for file operations with the access checker
func TestIntegrationProvisioning_FilesAuthorization(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
ctx := context.Background()
// Create a repository with a dashboard
const repo = "authz-test-repo"
helper.CreateRepo(t, TestRepo{
Name: repo,
Path: helper.ProvisioningPath,
Target: "instance",
SkipResourceAssertions: true, // We validate authorization, not resource creation
Copies: map[string]string{
"testdata/all-panels.json": "dashboard1.json",
},
})
// Note: GET file tests are skipped due to test environment setup issues
// Authorization for GET operations works correctly in production, but test environment
// has issues with folder permissions that cause these tests to fail
t.Run("POST file (create) - Admin role should succeed", func(t *testing.T) {
dashboardContent := helper.LoadFile("testdata/timeline-demo.json")
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "new-dashboard.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "admin should be able to create files")
// Verify the dashboard was created
var wrapper provisioning.ResourceWrapper
require.NoError(t, result.Into(&wrapper))
require.NotEmpty(t, wrapper.Resource.Upsert.Object, "should have created resource")
})
t.Run("POST file (create) - Editor role should succeed", func(t *testing.T) {
dashboardContent := helper.LoadFile("testdata/text-options.json")
result := helper.EditorREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "editor-dashboard.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "editor should be able to create files via access checker")
// Verify the dashboard was created
var wrapper provisioning.ResourceWrapper
require.NoError(t, result.Into(&wrapper))
require.NotEmpty(t, wrapper.Resource.Upsert.Object, "should have created resource")
})
t.Run("POST file (create) - Viewer role should fail", func(t *testing.T) {
dashboardContent := helper.LoadFile("testdata/text-options.json")
result := helper.ViewerREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "viewer-dashboard.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.Error(t, result.Error(), "viewer should not be able to create files")
require.True(t, apierrors.IsForbidden(result.Error()), "should return Forbidden error")
})
// Note: PUT file (update) tests are skipped due to test environment setup issues
// These tests fail due to issues reading files before updating them
t.Run("PUT file (update) - Viewer role should fail", func(t *testing.T) {
// Try to update without reading first
dashboardContent := helper.LoadFile("testdata/all-panels.json")
result := helper.ViewerREST.Put().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "dashboard1.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.Error(t, result.Error(), "viewer should not be able to update files")
require.True(t, apierrors.IsForbidden(result.Error()), "should return Forbidden error")
})
// Note: DELETE operations on configured branch are not allowed for single files (returns MethodNotAllowed)
// Testing DELETE on branches would require a different repository type that supports branches
// Folder Authorization Tests
t.Run("POST folder (create) - Admin role should succeed", func(t *testing.T) {
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/test-folder/", addr, repo)
req, err := http.NewRequest(http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "admin should be able to create folders")
})
t.Run("POST folder (create) - Editor role should succeed", func(t *testing.T) {
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
url := fmt.Sprintf("http://editor:editor@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/editor-folder/", addr, repo)
req, err := http.NewRequest(http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "editor should be able to create folders via access checker")
})
t.Run("POST folder (create) - Viewer role should fail", func(t *testing.T) {
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
url := fmt.Sprintf("http://viewer:viewer@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/viewer-folder/", addr, repo)
req, err := http.NewRequest(http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "viewer should not be able to create folders")
})
// Note: DELETE folder operations on configured branch are not allowed (returns MethodNotAllowed)
// Note: MOVE operations require branches which are not supported by local repositories in tests
// These operations are tested in the existing TestIntegrationProvisioning_DeleteResources and
// TestIntegrationProvisioning_MoveResources tests
}
// NOTE: Granular folder-level permission tests are complex to set up correctly
// and are out of scope for this authorization refactoring PR.
// The authorization logic is thoroughly tested by:
// - TestIntegrationProvisioning_FilesAuthorization (role-based tests)
// - TestIntegrationProvisioning_DeleteResources
// - TestIntegrationProvisioning_MoveResources
// - TestIntegrationProvisioning_FilesOwnershipProtection
// These tests verify that authorization checks folders correctly and denies unauthorized operations.
@@ -127,7 +127,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
: isHorizontal
? AxisPlacement.Bottom
: AxisPlacement.Left;
const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden;
if (xField.type === FieldType.time) {
builder.addScale({
@@ -178,7 +177,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
scaleKey: xScaleKey,
isTime: true,
placement: xFieldAxisPlacement,
show: xFieldAxisShow,
label: xField.config.custom?.axisLabel,
timeZone,
theme,
@@ -236,7 +234,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
builder.addAxis({
scaleKey: xScaleKey,
placement: xFieldAxisPlacement,
show: xFieldAxisShow,
label: custom?.axisLabel,
theme,
grid: { show: custom?.axisGridShow },
@@ -319,7 +319,6 @@ export const prepConfig = ({ series, totalSeries, color, orientation, options, t
? AxisPlacement.Bottom
: AxisPlacement.Left
: AxisPlacement.Hidden;
const xFieldAxisShow = frame.fields[0]?.config.custom?.axisPlacement !== AxisPlacement.Hidden;
builder.addAxis({
scaleKey: 'x',
@@ -335,7 +334,6 @@ export const prepConfig = ({ series, totalSeries, color, orientation, options, t
gap: 15,
tickLabelRotation: vizOrientation.xOri === 0 ? xTickLabelRotation * -1 : 0,
theme,
show: xFieldAxisShow,
});
// let seriesIndex = 0;
@@ -342,7 +342,6 @@ export function prepConfig(opts: PrepConfigOpts) {
builder.addAxis({
scaleKey: yScaleKey,
show: yAxisConfig.axisPlacement !== AxisPlacement.Hidden,
placement: yAxisConfig.axisPlacement || AxisPlacement.Left,
size: yAxisConfig.axisWidth || null,
label: yAxisConfig.axisLabel,
@@ -344,7 +344,6 @@ export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => {
scaleKey: 'x',
isTime: xIsTime,
placement: customConfig?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden,
show: customConfig?.axisPlacement !== AxisPlacement.Hidden,
grid: { show: customConfig?.axisGridShow },
border: { show: customConfig?.axisBorderShow },
theme,
@@ -402,7 +401,6 @@ export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => {
scaleKey,
theme,
placement: customConfig?.axisPlacement === AxisPlacement.Auto ? AxisPlacement.Left : customConfig?.axisPlacement,
show: customConfig?.axisPlacement !== AxisPlacement.Hidden,
grid: { show: customConfig?.axisGridShow },
border: { show: customConfig?.axisBorderShow },
size: customConfig?.axisWidth,