* feat(auth): add ExtraAudience option to RoundTripper Add ExtraAudience option to RoundTripper to allow operators to include additional audiences (e.g., provisioning group) when connecting to the multitenant aggregator. This ensures tokens include both the target API server's audience and the provisioning group audience, which is required to pass the enforceManagerProperties check. - Add ExtraAudience RoundTripperOption - Improve documentation and comments - Add comprehensive test coverage * fix(operators): add ExtraAudience for dashboards/folders API servers Operators connecting to dashboards and folders API servers need to include the provisioning group audience in addition to the target API server's audience to pass the enforceManagerProperties check. * provisioning: fix settings/stats authorization for AccessPolicy identities The settings and stats endpoints were returning 403 for users accessing via ST->MT because the AccessPolicy identity was routed to the access checker, which doesn't know about these resources. This fix handles 'settings' and 'stats' resources before the access checker path, routing them to the role-based authorization that allows: - settings: Viewer role (read-only, needed by frontend) - stats: Admin role (can leak information) * fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic - Removed legacy storage flag from useResourceStats hook in BootstrapStep. - Updated BootstrapStepResourceCounting to simplify rendering logic and removed target prop. - Adjusted tests to reflect changes in resource counting and rendering behavior. * Revert "fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic" This reverts commit148802cbb5. * provisioning: allow any authenticated user for settings/stats endpoints These are read-only endpoints needed by the frontend: - settings: returns available repository types and configuration for the wizard - stats: returns resource counts Authentication is verified before reaching authorization, so any user who reaches these endpoints is already authenticated. Requiring specific org roles failed for AccessPolicy tokens which don't carry traditional roles. * provisioning: remove redundant admin role check from listFolderFiles The admin role check in listFolderFiles was redundant (route-level auth already handles access) and broken for AccessPolicy identities which don't have org roles. File access is controlled by the AccessClient as documented in the route-level authorization comment. * provisioning: add isAdminOrAccessPolicy helper for auth checks Consolidates authorization logic for provisioning endpoints: - Adds isAdminOrAccessPolicy() helper that allows admin users OR AccessPolicy identities - AccessPolicy identities (ST->MT flow) are trusted internal callers without org roles - Regular users must have admin role (matching frontend navtree restriction) Used in: authorizeSettings, authorizeStats, authorizeJobs, listFolderFiles * provisioning: consolidate auth helpers into allowForAdminsOrAccessPolicy Simplifies authorization by: - Adding isAccessPolicy() helper for AccessPolicy identity check - Adding allowForAdminsOrAccessPolicy() that returns Decision directly - Consolidating stats/settings/jobs into single switch case - Using consistent pattern in files.go * provisioning: require admin for files subresource at route level Aligns route-level authorization with handler-level check in listFolderFiles. Both now require admin role OR AccessPolicy identity for consistency. * provisioning: restructure authorization with role-based helpers Reorganizes authorization code for clarity: Role-based helpers (all support AccessPolicy for ST->MT flow): - allowForAdminsOrAccessPolicy: admin role required - allowForEditorsOrAccessPolicy: editor role required - allowForViewersOrAccessPolicy: viewer role required Repository subresources by role: - Admin: repository CRUD, test, files - Editor: jobs, resources, sync, history - Viewer: refs, status (GET only) Connection subresources by role: - Admin: connection CRUD - Viewer: status (GET only) * provisioning: move refs to admin-only refs subresource now requires admin role (or AccessPolicy). Updated documentation comments to reflect current permissions. * provisioning: add fine-grained permissions for connections Adds connection permissions following the same pattern as repositories: - provisioning.connections:create - provisioning.connections:read - provisioning.connections:write - provisioning.connections:delete Roles: - fixed:provisioning.connections:reader (granted to Admin) - fixed:provisioning.connections:writer (granted to Admin) * provisioning: remove non-existent sync subresource from auth The sync subresource doesn't exist - syncing is done via the jobs endpoint. Removed dead code from authorization switch case. * provisioning: use access checker for fine-grained permissions Refactors authorization to use b.access.Check() with verb-based checks: Repository subresources: - CRUD: uses actual verb (get/create/update/delete) - test: uses 'update' (write permission) - files/refs/resources/history/status: uses 'get' (read permission) - jobs: uses actual verb for jobs resource Connection subresources: - CRUD: uses actual verb - status: uses 'get' (read permission) The access checker maps verbs to actions defined in accesscontrol.go. Falls back to admin role for backwards compatibility. Also removes redundant admin check from listFolderFiles since authorization is now properly handled at route level. * provisioning: use verb constants instead of string literals Uses apiutils.VerbGet, apiutils.VerbUpdate instead of "get", "update". * provisioning: use access checker for jobs and historicjobs resources Jobs resource: uses actual verb (create/read/write/delete) HistoricJobs resource: read-only (historicjobs:read) * provisioning: allow viewers to access settings endpoint Settings is read-only and needed by multiple UI pages (not just admin pages). Stats remains admin-only. * provisioning: consolidate role-based resource authorization Extract isRoleBasedResource() and authorizeRoleBasedResource() helpers to avoid duplicating settings/stats resource checks in multiple places. * provisioning: use resource name constants instead of hardcoded strings Replace 'repositories', 'connections', 'jobs', 'historicjobs' with their corresponding ResourceInfo.GetName() constants. * provisioning: delegate file authorization to connector Route level: allow any authenticated user for files subresource Connector: check repositories:read only for directory listing Individual file CRUD: handled by DualReadWriter based on actual resource * provisioning: enhance authorization for files and jobs resources Updated file authorization to fall back to admin role for listing files. Introduced checkAccessForJobs function to manage job permissions, allowing editors to create and manage jobs while maintaining admin-only access for historic jobs. Improved error messaging for permission denials. * provisioning: refactor authorization with fine-grained permissions Authorization changes: - Use access checker with role-based fallback for backwards compatibility - Repositories/Connections: admin role fallback - Jobs: editor role fallback (editors can manage jobs) - HistoricJobs: admin role fallback (read-only) - Settings: viewer role (needed by multiple UI pages) - Stats: admin role Files subresource: - Route level allows any authenticated user - Directory listing checks repositories:read in connector - Individual file CRUD delegated to DualReadWriter Refactored checkAccessWithFallback to accept fallback role parameter. * provisioning: refactor access checker integration for improved authorization Updated the authorization logic to utilize the new access checker across various resources, including files and jobs. This change simplifies the permission checks by removing redundant identity retrieval and enhances error handling. The access checker now supports role-based fallbacks for admin and editor roles, ensuring backward compatibility while streamlining the authorization process for repository and connection subresources. * provisioning: remove legacy access checker tests and refactor access checker implementation Deleted the access_checker_test.go file to streamline the codebase and focus on the updated access checker implementation. Refactored the access checker to enhance clarity and maintainability, ensuring it supports role-based fallback behavior. Updated the access checker integration in the API builder to utilize the new fallback role configuration, improving authorization logic across resources. * refactor: split AccessChecker into TokenAccessChecker and SessionAccessChecker - Renamed NewMultiTenantAccessChecker -> NewTokenAccessChecker (uses AuthInfoFrom) - Renamed NewSingleTenantAccessChecker -> NewSessionAccessChecker (uses GetRequester) - Split into separate files with their own tests - Added mockery-generated mock for AccessChecker interface - Names now reflect identity source rather than deployment mode * fix: correct error message case and use accessWithAdmin for filesConnector - Fixed error message to use lowercase 'admin role is required' - Fixed filesConnector to use accessWithAdmin for proper role fallback - Formatted code * refactor: reduce cyclomatic complexity in filesConnector.Connect Split the Connect handler into smaller focused functions: - handleRequest: main request processing - createDualReadWriter: setup dependencies - parseRequestOptions: extract request options - handleDirectoryListing: GET directory requests - handleMethodRequest: route to method handlers - handleGet/handlePost/handlePut/handleDelete: method-specific logic - handleMove: move operation logic * security: remove blind TypeAccessPolicy bypass from access checkers Removed the code that bypassed authorization for TypeAccessPolicy identities. All identities now go through proper permission verification via the inner access checker, which will validate permissions from ServiceIdentityClaims. This addresses the security concern where TypeAccessPolicy was being trusted blindly without verifying whether the identity came from the wire or in-process. * feat: allow editors to access repository refs subresource Change refs authorization from admin to editor fallback so editors can view repository branches when pushing changes to dashboards/folders. - Split refs from other read-only subresources (resources, history, status) - refs now uses accessWithEditor instead of accessWithAdmin - Updated documentation comment to reflect authorization levels - Added integration test TestIntegrationProvisioning_RefsPermissions verifying editor access and viewer denial * tests: add authorization tests for missing provisioning API endpoints Add comprehensive authorization tests for: - Repository subresources (test, resources, history, status) - Connection status subresource - HistoricJobs resource - Settings and Stats resources All authorization paths are now covered by integration tests. * test: fix RefsPermissions test to use GitHub repository Use github-readonly.json.tmpl template instead of local folder, since refs endpoint requires a versioned repository that supports git operations. * chore: format test files * fix: make settings/stats authorization work in MT mode Update authorizeRoleBasedResource to check authlib.AuthInfoFrom(ctx) for AccessPolicy identity type in addition to identity.GetRequester(ctx). This ensures AccessPolicy identities are recognized in MT mode where identity.GetRequester may not set the identity type correctly. * fix: remove unused authorization helper functions Remove allowForAdminsOrAccessPolicy and allowForViewersOrAccessPolicy as they are no longer used after refactoring to use authorizeRoleBasedResource. * Fix AccessPolicy identity detection in ST authorizer - Add check for AccessPolicy identities via GetAuthID() in authorizeRoleBasedResource - Extended JWT may set identity type to TypeUser but AuthID is 'access-policy:...' - Forward user ID token in X-Grafana-Id header in RoundTripper for aggregator forwarding * Revert "Fix AccessPolicy identity detection in ST authorizer" This reverts commit0f4885e503. * Add fine-grained permissions for settings and stats endpoints - Add provisioning.settings:read action (granted to Viewer role) - Add provisioning.stats:read action (granted to Admin role) - Add accessWithViewer to APIBuilder for Viewer role fallback - Use access checker for settings/stats authorization - Remove role-based authorization functions (isRoleBasedResource, authorizeRoleBasedResource) This makes settings and stats consistent with other provisioning resources and works properly in both ST and MT modes via the access checker. * Remove AUTHORIZATION_COVERAGE.md * Add provisioning resources to RBAC mapper - Add connections, settings, stats to provisioning.grafana.app mappings - Required for authz service to translate K8s verbs to legacy actions - Fixes 403 errors for settings/stats in MT mode * refactor: merge access checkers with original fallthrough behavior Merge tokenAccessChecker and sessionAccessChecker into a unified access checker that implements the original fallthrough behavior: 1. First try to get identity from access token (authlib.AuthInfoFrom) 2. If token exists AND (is TypeAccessPolicy OR useExclusivelyAccessCheckerForAuthz), use the access checker with token identity 3. If no token or conditions not met, fall back to session identity (identity.GetRequester) with optional role-based fallback This fixes the issue where settings/stats/connections endpoints were failing in MT mode because the tokenAccessChecker was returning an error when there was no auth info in context, instead of falling through to session-based authorization. The unified checker now properly handles: - MT mode: tries token first, falls back to session if no token - ST mode: only uses token for AccessPolicy identities, otherwise session - Role fallback: applies when configured and access checker denies * Revert "refactor: merge access checkers with original fallthrough behavior" This reverts commit96451f948b. * Grant settings view role to all * fix: use actual request verb for settings/stats authorization Use a.GetVerb() instead of hardcoded VerbGet for settings and stats authorization. When listing resources (hitting collection endpoint), the verb is 'list' not 'get', and this mismatch could cause issues with the RBAC service. * debug: add logging to access checkers for authorization debugging Add klog debug logs (V4 level) to token and session access checkers to help diagnose why settings/stats authorization is failing while connections works. * debug: improve access checker logging with grafana-app-sdk logger - Use grafana-app-sdk logging.FromContext instead of klog - Add error wrapping with resource.group format for better context - Log more details including folder, group, and allowed status - Log error.Error() for better error message visibility * chore: use generic log messages in access checkers * Revert "Grant settings view role to all" This reverts commit3f5758cf36. * fix: use request verb for historicjobs authorization The original role-based check allowed any verb for admins. To preserve this behavior with the access checker, we should pass the actual verb from the request instead of hardcoding VerbGet. --------- Co-authored-by: Charandas Batra <charandas.batra@grafana.com>
619 lines
20 KiB
Go
619 lines
20 KiB
Go
package resources
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
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/auth"
|
|
"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 auth.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 auth.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) {
|
|
// HACK: Get the has from repository -- this will avoid an additional RV increment
|
|
// we should change the signature of Create and Update to return FileInfo instead
|
|
info, _ = r.repo.Read(ctx, opts.Path, opts.Ref)
|
|
if info != nil {
|
|
parsed.Meta.SetSourceProperties(utils.SourceProperties{
|
|
Path: opts.Path,
|
|
Checksum: info.Hash,
|
|
})
|
|
}
|
|
|
|
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) {
|
|
// Reject directory move operations for configured branch - use bulk operations instead
|
|
if r.isConfiguredBranch(opts) {
|
|
return nil, &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Status: metav1.StatusFailure,
|
|
Code: http.StatusMethodNotAllowed,
|
|
Reason: metav1.StatusReasonMethodNotAllowed,
|
|
Message: "directory move operations are not available for configured branch. Use bulk move operations via the jobs API instead",
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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,
|
|
},
|
|
}
|
|
|
|
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 {
|
|
var name string
|
|
if parsed.Existing != nil {
|
|
name = parsed.Existing.GetName()
|
|
} else {
|
|
name = parsed.Obj.GetName()
|
|
}
|
|
|
|
return r.access.Check(ctx, authlib.CheckRequest{
|
|
Group: parsed.GVR.Group,
|
|
Resource: parsed.GVR.Resource,
|
|
Name: name,
|
|
Verb: verb,
|
|
}, parsed.Meta.GetFolder())
|
|
}
|
|
|
|
func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) error {
|
|
// Determine parent folder from path
|
|
parentFolder := ""
|
|
if path != "" {
|
|
parentPath := safepath.Dir(path)
|
|
if parentPath != "" {
|
|
parentFolder = ParseFolder(parentPath, r.repo.Config().Name).ID
|
|
} else {
|
|
parentFolder = RootFolder(r.repo.Config())
|
|
}
|
|
}
|
|
|
|
// For folder create operations, use empty name to check parent folder permissions
|
|
return r.access.Check(ctx, authlib.CheckRequest{
|
|
Group: FolderResource.Group,
|
|
Resource: FolderResource.Resource,
|
|
Name: "", // Empty name for create operations
|
|
Verb: utils.VerbCreate,
|
|
}, parentFolder)
|
|
}
|
|
|
|
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
|
|
// Reject directory delete operations for configured branch - use bulk operations instead
|
|
if r.isConfiguredBranch(opts) {
|
|
return nil, &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Status: metav1.StatusFailure,
|
|
Code: http.StatusMethodNotAllowed,
|
|
Reason: metav1.StatusReasonMethodNotAllowed,
|
|
Message: "directory delete operations are not available for configured branch. Use bulk delete operations via the jobs API instead",
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return nil, fmt.Errorf("error deleting folder from repository: %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
|
|
}
|
|
|
|
// isConfiguredBranch returns true if the ref targets the configured branch
|
|
// (empty ref means configured branch, or ref explicitly matches configured branch)
|
|
func (r *DualReadWriter) isConfiguredBranch(opts DualWriteOptions) bool {
|
|
configuredBranch := r.repo.Config().Branch()
|
|
return opts.Ref == "" || opts.Ref == configuredBranch
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
return r.isConfiguredBranch(opts)
|
|
}
|