f8069aebcf
* refactor: delegate authorization to access checker in dualwriter - Remove role-based authorization checks (editor/admin role checks) - Delegate all authorization to access checker which checks resource-level permissions - Update authorizeCreateFolder to use access checker instead of role-based checks - Add comprehensive authorization tests for viewer, editor, and admin roles - Tests cover GET, POST, PUT, DELETE operations and folder creation This change ensures that authorization is consistently handled through the access checker, which checks resource-level permissions rather than just organization roles. * fix: format files_test.go * fix: check error return value of resp.Body.Close() * fix: grant permissions to all dashboards for editor role in authorization test Use SetPermissions with wildcard to grant permissions to Editor user for all dashboards, not just the initial one. This ensures that dashboards created during tests (like in DELETE operations) have the necessary permissions for the editor role.
983 lines
38 KiB
Go
983 lines
38 KiB
Go
package provisioning
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
|
|
"github.com/grafana/grafana/pkg/util/testutil"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
)
|
|
|
|
func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
helper := runGrafana(t)
|
|
ctx := context.Background()
|
|
|
|
const repo = "delete-test-repo"
|
|
helper.CreateRepo(t, TestRepo{
|
|
Name: repo,
|
|
Path: helper.ProvisioningPath,
|
|
Target: "instance",
|
|
Copies: map[string]string{
|
|
"testdata/all-panels.json": "dashboard1.json",
|
|
"testdata/text-options.json": "folder/dashboard2.json",
|
|
"testdata/timeline-demo.json": "folder/nested/dashboard3.json",
|
|
"testdata/.keep": "folder/nested/.keep",
|
|
},
|
|
SkipResourceAssertions: true, // tested below
|
|
})
|
|
|
|
var dashboards *unstructured.UnstructuredList
|
|
var folders *unstructured.UnstructuredList
|
|
var err error
|
|
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
|
dashboards, err = helper.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
|
|
if err != nil {
|
|
collect.Errorf("could not list dashboards error: %s", err.Error())
|
|
return
|
|
}
|
|
if len(dashboards.Items) != 3 {
|
|
collect.Errorf("should have the expected dashboards after sync. got: %d. expected: %d", len(dashboards.Items), 2)
|
|
return
|
|
}
|
|
folders, err = helper.Folders.Resource.List(t.Context(), metav1.ListOptions{})
|
|
if err != nil {
|
|
collect.Errorf("could not list folders: error: %s", err.Error())
|
|
return
|
|
}
|
|
if len(folders.Items) != 2 {
|
|
collect.Errorf("should have the expected folders after sync. got: %d. expected: %d", len(folders.Items), 2)
|
|
return
|
|
}
|
|
|
|
assert.Len(collect, dashboards.Items, 3)
|
|
assert.Len(collect, folders.Items, 2)
|
|
}, waitTimeoutDefault, waitIntervalDefault, "should have the expected dashboards and folders after sync")
|
|
|
|
helper.validateManagedDashboardsFolderMetadata(t, ctx, repo, dashboards.Items)
|
|
|
|
t.Run("delete individual dashboard file on configured branch should succeed", func(t *testing.T) {
|
|
result := helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "dashboard1.json").
|
|
Do(ctx)
|
|
require.NoError(t, result.Error(), "delete file on configured branch should succeed")
|
|
|
|
// Verify the dashboard is removed from Grafana
|
|
const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json
|
|
_, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
|
|
require.Error(t, err, "dashboard should be deleted from Grafana")
|
|
require.True(t, apierrors.IsNotFound(err), "should return NotFound for deleted dashboard")
|
|
})
|
|
|
|
t.Run("delete individual dashboard file on branch should succeed", func(t *testing.T) {
|
|
// Create a branch first by creating a file on a branch
|
|
branchRef := "test-branch-delete"
|
|
helper.CopyToProvisioningPath(t, "testdata/text-options.json", "branch-test-delete.json")
|
|
|
|
// Delete on branch should work
|
|
result := helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "branch-test-delete.json").
|
|
Param("ref", branchRef).
|
|
Do(ctx)
|
|
// Note: This might fail if branch doesn't exist, but the important thing is it doesn't return MethodNotAllowed
|
|
if result.Error() != nil {
|
|
var statusErr *apierrors.StatusError
|
|
if errors.As(result.Error(), &statusErr) {
|
|
require.NotEqual(t, int32(http.StatusMethodNotAllowed), statusErr.ErrStatus.Code, "should not return MethodNotAllowed for branch delete")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("delete folder on configured branch should return MethodNotAllowed", func(t *testing.T) {
|
|
// need to delete directly through the url, because the k8s client doesn't support `/` in a subresource
|
|
// but that is needed by gitsync to know that it is a folder
|
|
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/folder/", addr, repo)
|
|
req, err := http.NewRequest(http.MethodDelete, 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.StatusMethodNotAllowed, resp.StatusCode, "should return MethodNotAllowed for configured branch folder delete")
|
|
|
|
// Verify a file inside the folder still exists (operation was rejected)
|
|
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json")
|
|
require.NoError(t, err, "file inside folder should still exist after rejected delete")
|
|
})
|
|
|
|
t.Run("deleting a non-existent file should fail", func(t *testing.T) {
|
|
result := helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "non-existent.json").
|
|
Do(ctx)
|
|
require.Error(t, result.Error())
|
|
})
|
|
}
|
|
|
|
func TestIntegrationProvisioning_MoveResources(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
helper := runGrafana(t)
|
|
ctx := context.Background()
|
|
repo := "move-test-repo"
|
|
helper.CreateRepo(t, TestRepo{
|
|
Name: repo,
|
|
Path: helper.ProvisioningPath,
|
|
Target: "instance",
|
|
Copies: map[string]string{
|
|
"testdata/all-panels.json": "all-panels.json",
|
|
},
|
|
ExpectedDashboards: 1,
|
|
ExpectedFolders: 0,
|
|
})
|
|
|
|
// Validate the dashboard metadata
|
|
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, len(dashboards.Items))
|
|
|
|
helper.validateManagedDashboardsFolderMetadata(t, ctx, repo, dashboards.Items)
|
|
|
|
// Verify the original dashboard exists in Grafana (using the UID from all-panels.json)
|
|
const allPanelsUID = "n1jR8vnnz" // This is the UID from the all-panels.json file
|
|
obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
|
|
require.NoError(t, err, "original dashboard should exist in Grafana")
|
|
require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity])
|
|
|
|
t.Run("move file without content change on configured branch should succeed", func(t *testing.T) {
|
|
const targetPath = "moved/simple-move.json"
|
|
|
|
// Perform the move operation using helper function (no ref = configured branch)
|
|
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
|
targetPath: targetPath,
|
|
originalPath: "all-panels.json",
|
|
message: "move file without content change",
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "move operation on configured branch should succeed")
|
|
|
|
// Verify file was moved - read from new location
|
|
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json")
|
|
require.NoError(t, err, "file should exist at new location")
|
|
|
|
// Verify file no longer exists at old location
|
|
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json")
|
|
require.Error(t, err, "file should not exist at old location")
|
|
})
|
|
|
|
t.Run("move file without content change on branch should succeed", func(t *testing.T) {
|
|
const targetPath = "moved/simple-move-branch.json"
|
|
branchRef := "test-branch-move"
|
|
|
|
// Perform the move operation using helper function with ref parameter
|
|
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
|
targetPath: targetPath,
|
|
originalPath: "all-panels.json",
|
|
message: "move file without content change",
|
|
ref: branchRef,
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
// Note: This might fail if branch doesn't exist, but the important thing is it doesn't return MethodNotAllowed
|
|
if resp.StatusCode == http.StatusMethodNotAllowed {
|
|
t.Fatal("should not return MethodNotAllowed for branch move")
|
|
}
|
|
|
|
// If move succeeded (not MethodNotAllowed), verify the file moved in the repository
|
|
if resp.StatusCode == http.StatusOK {
|
|
movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move-branch.json")
|
|
require.NoError(t, err, "moved file should exist in repository")
|
|
|
|
// Check the content is preserved (verify it's still the all-panels dashboard)
|
|
resource, _, err := unstructured.NestedMap(movedObj.Object, "resource")
|
|
require.NoError(t, err)
|
|
dryRun, _, err := unstructured.NestedMap(resource, "dryRun")
|
|
require.NoError(t, err)
|
|
title, _, err := unstructured.NestedString(dryRun, "spec", "title")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Panel tests - All panels", title, "content should be preserved")
|
|
}
|
|
})
|
|
|
|
t.Run("move file to nested path on configured branch should succeed", func(t *testing.T) {
|
|
// Test a different scenario: Move a file that was never synced to Grafana
|
|
// This might reveal the issue if dashboard creation fails during move
|
|
const sourceFile = "never-synced.json"
|
|
helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", sourceFile)
|
|
|
|
// DO NOT sync - move the file immediately without it ever being in Grafana
|
|
const targetPath = "deep/nested/timeline.json"
|
|
|
|
// Perform the move operation without the file ever being synced to Grafana (no ref = configured branch)
|
|
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
|
targetPath: targetPath,
|
|
originalPath: sourceFile,
|
|
message: "move never-synced file to nested path",
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "move operation on configured branch should succeed")
|
|
|
|
// File should exist at new location
|
|
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json")
|
|
require.NoError(t, err, "file should exist at new nested location")
|
|
|
|
// File should not exist at original location
|
|
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourceFile)
|
|
require.Error(t, err, "file should not exist at original location after move")
|
|
})
|
|
|
|
t.Run("move file with content update on configured branch should succeed", func(t *testing.T) {
|
|
const sourcePath = "moved/simple-move.json" // Use the file we moved earlier
|
|
const targetPath = "updated/content-updated.json"
|
|
|
|
// Use text-options.json content for the update
|
|
updatedContent := helper.LoadFile("testdata/text-options.json")
|
|
|
|
// Perform move with content update using helper function (no ref = configured branch)
|
|
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
|
targetPath: targetPath,
|
|
originalPath: sourcePath,
|
|
message: "move file with content update",
|
|
body: string(updatedContent),
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update on configured branch should succeed")
|
|
|
|
// File should exist at new location with updated content
|
|
movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "updated", "content-updated.json")
|
|
require.NoError(t, err, "file should exist at new location")
|
|
|
|
// Verify content was updated (should be text-options dashboard now)
|
|
resource, _, err := unstructured.NestedMap(movedObj.Object, "resource")
|
|
require.NoError(t, err)
|
|
dryRun, _, err := unstructured.NestedMap(resource, "dryRun")
|
|
require.NoError(t, err)
|
|
title, _, err := unstructured.NestedString(dryRun, "spec", "title")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Text options", title, "content should be updated")
|
|
|
|
// Source file should not exist anymore
|
|
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourcePath)
|
|
require.Error(t, err, "source file should not exist after move")
|
|
})
|
|
|
|
t.Run("move directory on configured branch should return MethodNotAllowed", func(t *testing.T) {
|
|
// Create some files in a directory first using existing testdata files
|
|
helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "source-dir/timeline-demo.json")
|
|
helper.CopyToProvisioningPath(t, "testdata/text-options.json", "source-dir/text-options.json")
|
|
|
|
// Sync to ensure files are recognized
|
|
helper.SyncAndWait(t, repo, nil)
|
|
|
|
const sourceDir = "source-dir/"
|
|
const targetDir = "moved-dir/"
|
|
|
|
// Move directory using helper function (no ref = configured branch)
|
|
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
|
targetPath: targetDir,
|
|
originalPath: sourceDir,
|
|
message: "move directory",
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "directory move on configured branch should return MethodNotAllowed")
|
|
|
|
// Verify files in source directory still exist (operation was rejected)
|
|
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir", "timeline-demo.json")
|
|
require.NoError(t, err, "file in source directory should still exist after rejected move")
|
|
})
|
|
|
|
t.Run("error cases", func(t *testing.T) {
|
|
t.Run("missing originalPath parameter", func(t *testing.T) {
|
|
result := helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "target.json").
|
|
Body([]byte(`{"test": "content"}`)).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
require.Error(t, result.Error(), "should fail without originalPath")
|
|
})
|
|
|
|
t.Run("file to directory type mismatch", func(t *testing.T) {
|
|
// First create a simple test file without slashes in the path
|
|
result := helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "simple-test.json").
|
|
Body(helper.LoadFile("testdata/all-panels.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
require.NoError(t, result.Error(), "should create test file")
|
|
|
|
// Now try to move this file to a directory path using helper function
|
|
resp := helper.postFilesRequest(t, repo, filesPostOptions{
|
|
targetPath: "target-dir/",
|
|
originalPath: "simple-test.json",
|
|
message: "test move",
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
// Read response body to check error message
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
require.NotEqual(t, http.StatusOK, resp.StatusCode, "should fail when moving file to directory")
|
|
require.Contains(t, string(body), "cannot move between file and directory types")
|
|
})
|
|
|
|
t.Run("non-existent source file", func(t *testing.T) {
|
|
result := helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "target.json").
|
|
Param("originalPath", "non-existent.json").
|
|
Body([]byte("")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
require.Error(t, result.Error(), "should fail when source file doesn't exist")
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
helper := runGrafana(t)
|
|
ctx := context.Background()
|
|
|
|
// create both repos concurrently to reduce duration of this test
|
|
// Create first repository targeting "folder-1" with its own subdirectory
|
|
const repo1 = "ownership-repo-1"
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
helper.CreateRepo(t, TestRepo{
|
|
Name: repo1,
|
|
Path: path.Join(helper.ProvisioningPath, "repo1"),
|
|
Target: "folder",
|
|
Copies: map[string]string{
|
|
"testdata/all-panels.json": "dashboard1.json",
|
|
},
|
|
SkipResourceAssertions: true, // will check both at the same time below to reduce duration of this test
|
|
})
|
|
}()
|
|
|
|
// Create second repository targeting "folder-2" with its own subdirectory
|
|
const repo2 = "ownership-repo-2"
|
|
path2 := path.Join(helper.ProvisioningPath, "repo2")
|
|
go func() {
|
|
defer wg.Done()
|
|
helper.CreateRepo(t, TestRepo{
|
|
Name: repo2,
|
|
Path: path2,
|
|
Target: "folder",
|
|
Copies: map[string]string{
|
|
"testdata/timeline-demo.json": "dashboard2.json",
|
|
},
|
|
SkipResourceAssertions: true, // will check both at the same time below to reduce duration of this test
|
|
})
|
|
}()
|
|
wg.Wait()
|
|
|
|
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
|
dashboards, err := helper.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
|
|
if err != nil {
|
|
collect.Errorf("could not list dashboards error: %s", err.Error())
|
|
return
|
|
}
|
|
if len(dashboards.Items) != 2 {
|
|
collect.Errorf("should have the expected dashboards after sync. got: %d. expected: %d", len(dashboards.Items), 2)
|
|
return
|
|
}
|
|
folders, err := helper.Folders.Resource.List(t.Context(), metav1.ListOptions{})
|
|
if err != nil {
|
|
collect.Errorf("could not list folders: error: %s", err.Error())
|
|
return
|
|
}
|
|
if len(folders.Items) != 2 {
|
|
collect.Errorf("should have the expected folders after sync. got: %d. expected: %d", len(folders.Items), 2)
|
|
return
|
|
}
|
|
|
|
assert.Len(collect, dashboards.Items, 2)
|
|
assert.Len(collect, folders.Items, 2)
|
|
}, waitTimeoutDefault, waitIntervalDefault, "should have the expected dashboards and folders after sync")
|
|
|
|
allDashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
|
|
require.NoError(t, err)
|
|
for _, dashboard := range allDashboards.Items {
|
|
annotations := dashboard.GetAnnotations()
|
|
// Expect to be managed by repo1 or repo2
|
|
managerID := annotations["grafana.app/managerId"]
|
|
if managerID != repo1 && managerID != repo2 {
|
|
t.Fatalf("dashboard %s is not managed by repo1 or repo2", dashboard.GetName())
|
|
}
|
|
}
|
|
|
|
t.Run("CREATE file with UID already owned by different repository - should fail", func(t *testing.T) {
|
|
// Try to create a dashboard in repo2 that has the same UID as the one in repo1
|
|
// The all-panels.json has UID "n1jR8vnnz" which is already owned by repo1
|
|
result := helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo2). // Using repo2 to try to create resource with same UID as repo1
|
|
SubResource("files", "conflicting-dashboard.json").
|
|
Body(helper.LoadFile("testdata/all-panels.json")). // Same file = same UID
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
// This should fail with ownership conflict
|
|
require.Error(t, result.Error(), "creating resource with UID already owned by different repository should fail")
|
|
|
|
// Get detailed error information
|
|
err := result.Error()
|
|
t.Logf("CREATE operation error: %T - %v", err, err)
|
|
if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) {
|
|
t.Logf("Status error details: code=%d, reason=%s, message=%s",
|
|
statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message)
|
|
}
|
|
|
|
// Verify it returns BadRequest (400) for ownership conflicts
|
|
if !apierrors.IsBadRequest(err) {
|
|
t.Errorf("Expected BadRequest error but got: %T - %v", err, err)
|
|
return
|
|
}
|
|
|
|
// Check error message contains ownership conflict information
|
|
errorMsg := err.Error()
|
|
t.Logf("Error message: %s", errorMsg)
|
|
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
|
|
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
|
|
})
|
|
|
|
t.Run("UPDATE with UID already owned by different repository - should fail", func(t *testing.T) {
|
|
// Try to update the dashboard owned by repo1 using repo2
|
|
result := helper.AdminREST.Put().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo2). // Using repo2 to try to update repo1's resource
|
|
SubResource("files", "conflicting-update.json").
|
|
Body(helper.LoadFile("testdata/all-panels.json")). // Same UID as repo1's dashboard
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
// This should fail with ownership conflict
|
|
require.Error(t, result.Error(), "updating resource owned by different repository should fail")
|
|
|
|
// Get detailed error information
|
|
err := result.Error()
|
|
t.Logf("UPDATE operation error: %T - %v", err, err)
|
|
if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) {
|
|
t.Logf("Status error details: code=%d, reason=%s, message=%s",
|
|
statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message)
|
|
}
|
|
|
|
// Verify it returns BadRequest (400) for ownership conflicts
|
|
if !apierrors.IsBadRequest(err) {
|
|
t.Errorf("Expected BadRequest error but got: %T - %v", err, err)
|
|
return
|
|
}
|
|
|
|
// Check error message contains ownership conflict information
|
|
errorMsg := err.Error()
|
|
t.Logf("Error message: %s", errorMsg)
|
|
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
|
|
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
|
|
})
|
|
|
|
t.Run("DELETE resource owned by different repository - should fail", func(t *testing.T) {
|
|
// Create a file manually in the second repo which has UID from first repo
|
|
helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "repo2/conflicting-delete.json")
|
|
printFileTree(t, helper.ProvisioningPath)
|
|
|
|
result := helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo2).
|
|
SubResource("files", "conflicting-delete.json").
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
// This should fail with ownership conflict
|
|
require.Error(t, result.Error(), "deleting resource owned by different repository should fail")
|
|
|
|
// Get detailed error information
|
|
err := result.Error()
|
|
t.Logf("DELETE operation error: %T - %v", err, err)
|
|
if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) {
|
|
t.Logf("Status error details: code=%d, reason=%s, message=%s",
|
|
statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message)
|
|
}
|
|
|
|
// Verify it returns BadRequest (400) for ownership conflicts
|
|
require.True(t, apierrors.IsBadRequest(err), "Expected BadRequest error but got: %T - %v", err, err)
|
|
|
|
// Check error message contains ownership conflict information
|
|
errorMsg := err.Error()
|
|
t.Logf("Error message: %s", errorMsg)
|
|
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
|
|
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
|
|
})
|
|
|
|
t.Run("MOVE and UPDATE file with UID already owned by different repository - should fail", func(t *testing.T) {
|
|
resp := helper.postFilesRequest(t, repo2, filesPostOptions{
|
|
targetPath: "moved-dashboard.json",
|
|
originalPath: path.Join("dashboard2.json"),
|
|
message: "attempt to move file from different repository",
|
|
body: string(helper.LoadFile("testdata/all-panels.json")), // Content with the conflicting UID
|
|
})
|
|
// nolint:errcheck
|
|
defer resp.Body.Close()
|
|
|
|
// This should fail with ownership conflict
|
|
require.NotEqual(t, http.StatusOK, resp.StatusCode, "moving resource owned by different repository should fail")
|
|
// Read response body to check error message
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
errorMsg := string(body)
|
|
|
|
// Log detailed error information
|
|
t.Logf("MOVE operation HTTP status: %d", resp.StatusCode)
|
|
t.Logf("MOVE operation error response: %s", errorMsg)
|
|
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return BadRequest (400) for ownership conflict")
|
|
// Check error message contains ownership conflict information
|
|
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
|
|
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
|
|
})
|
|
|
|
t.Run("verify original resources remain intact", func(t *testing.T) {
|
|
const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (repo1)
|
|
const timelineUID = "mIJjFy8Kz" // UID from timeline-demo.json (repo2)
|
|
|
|
// Verify repo1's dashboard is still owned by repo1
|
|
dashboard1, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
|
|
require.NoError(t, err, "repo1's dashboard should still exist")
|
|
require.Equal(t, repo1, dashboard1.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo1's dashboard should still be owned by repo1")
|
|
|
|
// Verify repo2's dashboard is still owned by repo2
|
|
dashboard2, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{})
|
|
require.NoError(t, err, "repo2's dashboard should still exist")
|
|
require.Equal(t, repo2, dashboard2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo2's dashboard should still be owned by repo2")
|
|
})
|
|
}
|
|
|
|
func TestIntegrationProvisioning_FilesAuthorization(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
helper := runGrafana(t)
|
|
ctx := context.Background()
|
|
|
|
const repo = "auth-test-repo"
|
|
helper.CreateRepo(t, TestRepo{
|
|
Name: repo,
|
|
Path: helper.ProvisioningPath,
|
|
Target: "instance",
|
|
Copies: map[string]string{
|
|
"testdata/all-panels.json": "dashboard1.json",
|
|
},
|
|
ExpectedDashboards: 1,
|
|
ExpectedFolders: 0,
|
|
})
|
|
|
|
// Wait for initial sync to complete
|
|
var dashboardUID string
|
|
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
|
dashboards, err := helper.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
|
|
if err != nil {
|
|
collect.Errorf("could not list dashboards error: %s", err.Error())
|
|
return
|
|
}
|
|
if len(dashboards.Items) != 1 {
|
|
collect.Errorf("should have the expected dashboards after sync. got: %d. expected: %d", len(dashboards.Items), 1)
|
|
return
|
|
}
|
|
assert.Len(collect, dashboards.Items, 1)
|
|
dashboardUID = dashboards.Items[0].GetName()
|
|
}, waitTimeoutDefault, waitIntervalDefault, "should have the expected dashboards after sync")
|
|
|
|
// Grant permissions to Editor user for all dashboards using wildcard
|
|
// The access checker checks resource-level permissions, so we need to grant them
|
|
// Using wildcard "*" to grant permissions to all dashboards (including ones created during tests)
|
|
// Note: Viewer role gets permissions via HTTP API below, Editor gets them here via SetPermissions
|
|
helper.SetPermissions(helper.Org1.Editor, []resourcepermissions.SetResourcePermissionCommand{
|
|
{
|
|
Actions: []string{"dashboards:read", "dashboards:write", "dashboards:delete"},
|
|
Resource: "dashboards",
|
|
ResourceAttribute: "uid",
|
|
ResourceID: "*",
|
|
},
|
|
})
|
|
|
|
// Grant view permission to Viewer role via HTTP API (for the initial dashboard)
|
|
// Note: This only grants permissions to the initial dashboard, but viewers should be able to read all
|
|
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
|
setDashboardPermissions := func(permissions []map[string]interface{}) {
|
|
payload := map[string]interface{}{
|
|
"items": permissions,
|
|
}
|
|
payloadBytes, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
url := fmt.Sprintf("http://admin:admin@%s/api/dashboards/uid/%s/permissions", addr, dashboardUID)
|
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.NoError(t, resp.Body.Close())
|
|
}
|
|
|
|
// Grant view permission to Viewer role for the initial dashboard
|
|
setDashboardPermissions([]map[string]interface{}{
|
|
{"role": "Viewer", "permission": 1}, // View permission
|
|
})
|
|
|
|
t.Run("GET operations", func(t *testing.T) {
|
|
t.Run("viewer can GET files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.ViewerREST.Get().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "dashboard1.json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "viewer should be able to GET files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
})
|
|
|
|
t.Run("editor can GET files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.EditorREST.Get().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "dashboard1.json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "editor should be able to GET files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
})
|
|
|
|
t.Run("admin can GET files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.AdminREST.Get().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "dashboard1.json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "admin should be able to GET files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
})
|
|
})
|
|
|
|
t.Run("POST operations", func(t *testing.T) {
|
|
t.Run("viewer cannot POST files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.ViewerREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "viewer-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.Error(t, result.Error(), "viewer should not be able to POST files")
|
|
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
|
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
|
})
|
|
|
|
t.Run("editor can POST files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.EditorREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "editor-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "editor should be able to POST files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
|
|
// Clean up
|
|
helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "editor-test.json").
|
|
Do(ctx)
|
|
})
|
|
|
|
t.Run("admin can POST files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "admin-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "admin should be able to POST files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
|
|
// Clean up
|
|
helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "admin-test.json").
|
|
Do(ctx)
|
|
})
|
|
})
|
|
|
|
t.Run("PUT operations", func(t *testing.T) {
|
|
// Create a test file first using admin
|
|
helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "update-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
t.Run("viewer cannot PUT files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.ViewerREST.Put().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "update-test.json").
|
|
Body(helper.LoadFile("testdata/timeline-demo.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.Error(t, result.Error(), "viewer should not be able to PUT files")
|
|
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
|
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
|
})
|
|
|
|
t.Run("editor can PUT files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.EditorREST.Put().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "update-test.json").
|
|
Body(helper.LoadFile("testdata/timeline-demo.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "editor should be able to PUT files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
})
|
|
|
|
t.Run("admin can PUT files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.AdminREST.Put().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "update-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "admin should be able to PUT files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
})
|
|
|
|
// Clean up
|
|
helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "update-test.json").
|
|
Do(ctx)
|
|
})
|
|
|
|
t.Run("DELETE operations", func(t *testing.T) {
|
|
// Create test files for deletion tests
|
|
helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "delete-viewer-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "delete-editor-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
helper.AdminREST.Post().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "delete-admin-test.json").
|
|
Body(helper.LoadFile("testdata/text-options.json")).
|
|
SetHeader("Content-Type", "application/json").
|
|
Do(ctx)
|
|
|
|
t.Run("viewer cannot DELETE files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.ViewerREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "delete-viewer-test.json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.Error(t, result.Error(), "viewer should not be able to DELETE files")
|
|
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
|
|
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
|
|
|
|
// Verify file still exists
|
|
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "delete-viewer-test.json")
|
|
require.NoError(t, err, "file should still exist after failed delete")
|
|
})
|
|
|
|
t.Run("editor can DELETE files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.EditorREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "delete-editor-test.json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "editor should be able to DELETE files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
|
|
// Verify file was deleted
|
|
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "delete-editor-test.json")
|
|
require.Error(t, err, "file should be deleted")
|
|
require.True(t, apierrors.IsNotFound(err), "should return NotFound for deleted file")
|
|
})
|
|
|
|
t.Run("admin can DELETE files", func(t *testing.T) {
|
|
var statusCode int
|
|
result := helper.AdminREST.Delete().
|
|
Namespace("default").
|
|
Resource("repositories").
|
|
Name(repo).
|
|
SubResource("files", "delete-admin-test.json").
|
|
Do(ctx).StatusCode(&statusCode)
|
|
|
|
require.NoError(t, result.Error(), "admin should be able to DELETE files")
|
|
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
|
|
|
|
// Verify file was deleted
|
|
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "delete-admin-test.json")
|
|
require.Error(t, err, "file should be deleted")
|
|
require.True(t, apierrors.IsNotFound(err), "should return NotFound for deleted file")
|
|
})
|
|
})
|
|
|
|
t.Run("folder operations", func(t *testing.T) {
|
|
t.Run("viewer cannot create folders", func(t *testing.T) {
|
|
// Create a folder by POSTing to a directory path
|
|
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/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.StatusForbidden, resp.StatusCode, "viewer should not be able to create folders")
|
|
})
|
|
|
|
t.Run("editor can create folders", 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")
|
|
|
|
// Clean up - delete folder
|
|
deleteURL := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/editor-folder/", addr, repo)
|
|
deleteReq, err := http.NewRequest(http.MethodDelete, deleteURL, nil)
|
|
require.NoError(t, err)
|
|
deleteResp, err := http.DefaultClient.Do(deleteReq)
|
|
require.NoError(t, err)
|
|
// nolint:errcheck
|
|
defer deleteResp.Body.Close()
|
|
})
|
|
|
|
t.Run("admin can create folders", 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/admin-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")
|
|
|
|
// Clean up - delete folder
|
|
deleteURL := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/admin-folder/", addr, repo)
|
|
deleteReq, err := http.NewRequest(http.MethodDelete, deleteURL, nil)
|
|
require.NoError(t, err)
|
|
deleteResp, err := http.DefaultClient.Do(deleteReq)
|
|
require.NoError(t, err)
|
|
// nolint:errcheck
|
|
defer deleteResp.Body.Close()
|
|
})
|
|
})
|
|
}
|