Files
Roberto Jiménez Sánchez 746efb4c56 Provisioning: Include ref field in DELETE endpoint response for branch operations (#113615)
Fix: Include ref field in DELETE endpoint response for branch operations

When deleting a dashboard file via DELETE endpoint with a ref query parameter
(for branch operations), the response was missing the ref field. This caused
the frontend branch workflow success handler to fail silently.

The issue was an inverted boolean condition in the Delete method. The code
was setting file.Ref = opts.Ref when shouldUpdateGrafanaDB returned true
(main branch operations), but it should have been setting it when false
(branch operations), since we read the file with an empty ref.

Fixed by inverting the condition from:
  if r.shouldUpdateGrafanaDB(opts, nil)
to:
  if !r.shouldUpdateGrafanaDB(opts, nil)

This ensures the ref field is properly included in the ResourceWrapper
response for branch operations.
2025-11-10 13:34:46 +01:00

712 lines
23 KiB
Go

package resources
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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"
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
// 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
access authlib.AccessChecker
}
type DualWriteOptions struct {
Path string
// Ref is the target branch
// Local repositories do not use this, all other repository types do.
// Empty ref means to target the configured default branch
Ref string
Message string
Data []byte
SkipDryRun bool
OriginalPath string // Used for move operations
Branch string // Configured default branch
}
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) {
// TODO: implement this
if safepath.IsDir(path) {
return nil, fmt.Errorf("folder read not supported")
}
info, err := r.repo.Read(ctx, path, ref)
if err != nil {
_, ok := utils.ExtractApiErrorStatus(err)
if ok {
return nil, err
}
return nil, fmt.Errorf("Read file failed: %w", err)
}
parsed, err := r.parser.Parse(ctx, info)
if err != nil {
return nil, apierrors.NewBadRequest(fmt.Sprintf("Parse file failed: %v", err))
}
// Fail as we use the dry run for this response and it's not about updating the resource
if err := parsed.DryRun(ctx); err != nil {
return nil, fmt.Errorf("error running dryRun: %w", err)
}
// Authorize based on the existing resource
if err = r.authorize(ctx, parsed, utils.VerbGet); err != nil {
return nil, err
}
return parsed, nil
}
func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
if safepath.IsDir(opts.Path) {
return r.deleteFolder(ctx, opts)
}
// Read the file from the default branch as it won't exist in the possibly new branch
file, err := r.repo.Read(ctx, opts.Path, "")
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
// HACK: manual set to the provided branch so that the parser can possible read the file
if !r.shouldUpdateGrafanaDB(opts, nil) {
file.Ref = opts.Ref
}
// TODO: document in API specification
// We can only delete parsable things
parsed, err := r.parser.Parse(ctx, file)
if err != nil {
return nil, fmt.Errorf("parse file: %w", err)
}
if err = r.authorize(ctx, parsed, utils.VerbDelete); err != nil {
return nil, err
}
parsed.Action = provisioning.ResourceActionDelete
// Use the parser's DryRun method like create/update operations
if !opts.SkipDryRun {
if err := parsed.DryRun(ctx); err != nil {
return nil, fmt.Errorf("error running dryRun for delete: %w", err)
}
}
err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("delete file from repository: %w", err)
}
// Delete the file in the grafana database using the parser's Run method
if r.shouldUpdateGrafanaDB(opts, nil) {
err = parsed.Run(ctx)
if err != nil {
return nil, fmt.Errorf("delete resource from storage: %w", err)
}
}
return parsed, err
}
// 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 := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
if !safepath.IsDir(opts.Path) {
return nil, fmt.Errorf("not a folder path")
}
if err := r.authorizeCreateFolder(ctx, opts.Path); err != nil {
return nil, err
}
// Now actually create the folder
if err := r.repo.Create(ctx, opts.Path, opts.Ref, nil, opts.Message); err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
cfg := r.repo.Config()
wrap := &provisioning.ResourceWrapper{
Path: opts.Path,
Ref: opts.Ref,
Repository: provisioning.ResourceRepositoryInfo{
Type: cfg.Spec.Type,
Namespace: cfg.Namespace,
Name: cfg.Name,
Title: cfg.Spec.Title,
},
Resource: provisioning.ResourceObjects{
Action: provisioning.ResourceActionCreate,
},
}
urls, err := getFolderURLs(ctx, opts.Path, opts.Ref, r.repo)
if err != nil {
return nil, err
}
wrap.URLs = urls
if r.shouldUpdateGrafanaDB(opts, nil) {
folderName, err := r.folders.EnsureFolderPathExist(ctx, opts.Path)
if err != nil {
return nil, err
}
current, err := r.folders.GetFolder(ctx, folderName)
if err != nil && !apierrors.IsNotFound(err) {
return nil, err // unable to check if the folder exists
}
wrap.Resource.Upsert = v0alpha1.Unstructured{
Object: current.Object,
}
}
return wrap, nil
}
// CreateResource creates a new resource in the repository
func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
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
}
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 create", "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)
}
// Verify that we can create (or update) the referenced resource
verb := utils.VerbUpdate
if parsed.Action == provisioning.ResourceActionCreate {
verb = utils.VerbCreate
}
if err = r.authorize(ctx, parsed, verb); err != nil {
return nil, err
}
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)
}
// 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
}
// 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
}
// 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 := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
if opts.OriginalPath == "" {
return nil, fmt.Errorf("originalPath is required for move operations")
}
// Validate that both paths are either files or directories (consistent types)
// Files should end without '/', directories should end with '/'
sourceIsDir := safepath.IsDir(opts.OriginalPath)
targetIsDir := safepath.IsDir(opts.Path)
if sourceIsDir != targetIsDir {
return nil, fmt.Errorf("cannot move between file and directory types - source is %s, target is %s",
getPathType(sourceIsDir), getPathType(targetIsDir))
}
// Handle directory moves separately (no parsing/authorization needed)
if sourceIsDir {
return r.moveDirectory(ctx, opts)
}
// Handle file moves with parsing and authorization
return r.moveFile(ctx, opts)
}
func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// For directory moves, we just perform the repository move without parsing
// Always use the provisioning identity when writing
ctx, _, err := identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
return nil, fmt.Errorf("unable to use provisioning identity: %w", err)
}
// Perform the move operation in the repository
if err = r.repo.Move(ctx, opts.OriginalPath, opts.Path, opts.Ref, opts.Message); err != nil {
return nil, fmt.Errorf("move directory in repository: %w", err)
}
// Create a basic parsed resource response for directories
cfg := r.repo.Config()
parsed := &ParsedResource{
Action: provisioning.ResourceActionMove,
Info: &repository.FileInfo{
Path: opts.Path,
Ref: opts.Ref,
},
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: cfg.Spec.Type,
Namespace: cfg.Namespace,
Name: cfg.Name,
Title: cfg.Spec.Title,
},
}
// Handle folder management for main branch
if r.shouldUpdateGrafanaDB(opts, nil) {
// Ensure destination folder path exists
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure destination folder path exists: %w", err)
}
// Try to delete the old folder structure from grafana (if it exists)
// This handles cleanup when folders are moved to new locations
oldFolderName, err := r.folders.EnsureFolderPathExist(ctx, opts.OriginalPath)
if err != nil {
return nil, fmt.Errorf("ensure original folder path exists: %w", err)
}
if oldFolderName != "" {
oldFolder, err := r.folders.GetFolder(ctx, oldFolderName)
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("get old folder for cleanup: %w", err)
}
if err == nil {
err = r.folders.Client().Delete(ctx, oldFolder.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("delete old folder from storage: %w", err)
}
}
}
}
return parsed, nil
}
func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// Read the original file to get its content for parsing and authorization
originalFile, err := r.repo.Read(ctx, opts.OriginalPath, "")
if err != nil {
return nil, fmt.Errorf("read original file: %w", err)
}
// Parse the original file to check permissions
parsed, err := r.parser.Parse(ctx, originalFile)
if err != nil {
return nil, fmt.Errorf("parse original file: %w", err)
}
// 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)
}
// Determine the content to use for the destination
// If new content is provided in opts.Data, use it; otherwise use original content
var destinationData []byte
if len(opts.Data) > 0 {
destinationData = opts.Data
} else {
destinationData = originalFile.Data
}
// Create new parsed resource with updated path and content
newInfo := &repository.FileInfo{
Data: destinationData,
Path: opts.Path,
Ref: opts.Ref,
}
newParsed, err := r.parser.Parse(ctx, newInfo)
if err != nil {
return nil, fmt.Errorf("parse new file: %w", err)
}
// Make sure the new resource is valid
if !opts.SkipDryRun {
if err := newParsed.DryRun(ctx); err != nil {
logger := logging.FromContext(ctx).With("path", opts.Path, "originalPath", opts.OriginalPath, "name", newParsed.Obj.GetName(), "ref", opts.Ref)
logger.Warn("failed to dry run resource on move", "error", err)
return nil, fmt.Errorf("error running dryRun on moved resource: %w", err)
}
}
if len(newParsed.Errors) > 0 {
return nil, fmt.Errorf("errors while parsing moved file [%v]", newParsed.Errors)
}
// Authorize create on the new path
verb := utils.VerbCreate
if newParsed.Action == provisioning.ResourceActionUpdate {
verb = utils.VerbUpdate
}
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()
if err != nil {
return nil, err
}
// Always use the provisioning identity when writing
ctx, _, err = identity.WithProvisioningIdentity(ctx, newParsed.Obj.GetNamespace())
if err != nil {
return nil, fmt.Errorf("unable to use provisioning identity: %w", err)
}
// Perform the move operation in the repository
// If we have new content, we need to update the file content as part of the move
if len(opts.Data) > 0 {
// FIXME: I think we should MOVE + UPDATE instead of Delete / Create
// For moves with content updates, we need to delete the old file and create the new one
if err = r.repo.Delete(ctx, opts.OriginalPath, opts.Ref, opts.Message); err != nil {
return nil, fmt.Errorf("delete original file in repository: %w", err)
}
if err = r.repo.Create(ctx, opts.Path, opts.Ref, data, opts.Message); err != nil {
return nil, fmt.Errorf("create moved file with new content in repository: %w", err)
}
} else {
// For simple moves without content changes, use the move operation
if err = r.repo.Move(ctx, opts.OriginalPath, opts.Path, opts.Ref, opts.Message); err != nil {
return nil, fmt.Errorf("move file in repository: %w", err)
}
}
// Update the grafana database if this is the main branch
if r.shouldUpdateGrafanaDB(opts, newParsed) {
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure folder path exists: %w", err)
}
// Delete the old resource from grafana if name changed
if newParsed.Obj.GetName() != parsed.Obj.GetName() {
err = parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("delete original resource from storage: %w", err)
}
}
// Create/update the new resource in grafana
err = newParsed.Run(ctx)
if err != nil {
return nil, fmt.Errorf("create moved resource in storage: %w", err)
}
}
newParsed.Action = provisioning.ResourceActionMove
return newParsed, nil
}
func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error {
id, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized(err.Error())
}
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) {
// if the ref is set, it is not the active branch, so just delete the files from the branch
// and do not delete the items from grafana itself
if !r.shouldUpdateGrafanaDB(opts, nil) {
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("error deleting folder from repository: %w", err)
}
return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
// before deleting from the repo, first get all children resources to delete from grafana afterwards
treeEntries, err := r.repo.ReadTree(ctx, "")
if err != nil {
return nil, fmt.Errorf("read repository tree: %w", err)
}
// note: parsedFolders will include the folder itself
parsedResources, parsedFolders, err := r.getChildren(ctx, opts.Path, treeEntries)
if err != nil {
return nil, fmt.Errorf("parse resources in folder: %w", err)
}
// delete from the repo
err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("delete folder from repository: %w", err)
}
// delete from grafana
ctx, _, err = identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
return nil, err
}
if err := r.deleteChildren(ctx, parsedResources, parsedFolders); err != nil {
return nil, fmt.Errorf("delete folder from grafana: %w", err)
}
return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
func getFolderURLs(ctx context.Context, path, ref string, repo repository.Repository) (*provisioning.RepositoryURLs, error) {
if urlRepo, ok := repo.(repository.RepositoryWithURLs); ok && ref != "" {
urls, err := urlRepo.ResourceURLs(ctx, &repository.FileInfo{Path: path, Ref: ref})
if err != nil {
return nil, err
}
return urls, nil
}
return nil, nil
}
// getPathType returns a human-readable description of the path type
func getPathType(isDir bool) string {
if isDir {
return "directory (ends with '/')"
}
return "file (no trailing '/')"
}
func folderDeleteResponse(ctx context.Context, path, ref string, repo repository.Repository) (*ParsedResource, error) {
urls, err := getFolderURLs(ctx, path, ref, repo)
if err != nil {
return nil, err
}
parsed := &ParsedResource{
Action: provisioning.ResourceActionDelete,
Info: &repository.FileInfo{
Path: path,
Ref: ref,
},
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: repo.Config().Spec.Type,
Namespace: repo.Config().Namespace,
Name: repo.Config().Name,
Title: repo.Config().Spec.Title,
},
URLs: urls,
}
return parsed, nil
}
func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) {
var resourcesInFolder []repository.FileTreeEntry
var foldersInFolder []Folder
for _, entry := range treeEntries {
// make sure the path is supported (i.e. not ignored by git sync) and that the path is the folder itself or a child of the folder
if IsPathSupported(entry.Path) != nil || !safepath.InDir(entry.Path, folderPath) {
continue
}
// folders cannot be parsed as resources, so handle them separately
if entry.Blob {
resourcesInFolder = append(resourcesInFolder, entry)
} else {
folder := ParseFolder(entry.Path, r.repo.Config().Name)
foldersInFolder = append(foldersInFolder, folder)
}
}
parsedResources := make([]*ParsedResource, len(resourcesInFolder))
for i, entry := range resourcesInFolder {
fileInfo, err := r.repo.Read(ctx, entry.Path, "")
if err != nil && !apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf("could not find resource in repository: %w", err)
}
parsed, err := r.parser.Parse(ctx, fileInfo)
if err != nil {
return nil, nil, fmt.Errorf("could not parse resource: %w", err)
}
parsedResources[i] = parsed
}
return parsedResources, foldersInFolder, nil
}
func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources []*ParsedResource, folders []Folder) error {
for _, parsed := range childrenResources {
err := parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete nested resource from grafana: %w", err)
}
}
// we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it
safepath.SortByDepth(folders, func(f Folder) string { return f.Path }, false)
for _, f := range folders {
err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("failed to delete folder from grafana: %w", err)
}
}
return nil
}
// shouldUpdateGrafanaDB returns true if we have an empty ref (targeting the configured branch)
// or if the ref matches the configured branch
func (r *DualReadWriter) shouldUpdateGrafanaDB(opts DualWriteOptions, parsed *ParsedResource) bool {
if parsed != nil && parsed.Client == nil {
return false
}
if opts.Ref != "" && opts.Ref != opts.Branch {
return false
}
return true
}