mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 06:12:49 +08:00
529 lines
16 KiB
Go
529 lines
16 KiB
Go
package test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/grafana/authlib/types"
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// RunStorageServerTest runs the storage server test suite
|
|
func RunStorageServerTest(t *testing.T, newBackend NewBackendFunc) {
|
|
runTestResourcePermissionScenarios(t, newBackend(context.Background()), GenerateRandomNSPrefix())
|
|
runTestListTrashAccessControl(t, newBackend(context.Background()), GenerateRandomNSPrefix())
|
|
}
|
|
|
|
// func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
|
|
func runTestResourcePermissionScenarios(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
|
|
// Test user
|
|
testUser := &identity.StaticRequester{
|
|
Type: types.TypeUser,
|
|
Login: "testuser",
|
|
UserID: 123,
|
|
UserUID: "u123",
|
|
OrgRole: identity.RoleAdmin,
|
|
IsGrafanaAdmin: true,
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
initialFolder string
|
|
targetFolder string
|
|
permissionMap map[string]bool
|
|
expectSuccess bool
|
|
expectedChecks int
|
|
expectedError int32 // HTTP status code for error, 0 if no error expected
|
|
}{
|
|
{
|
|
name: "Create resource in folder",
|
|
initialFolder: "folder1",
|
|
targetFolder: "", // No move, just create
|
|
permissionMap: map[string]bool{"folder1:create": true},
|
|
expectSuccess: true,
|
|
expectedChecks: 1,
|
|
expectedError: 0,
|
|
},
|
|
{
|
|
name: "Create resource denied",
|
|
initialFolder: "folder1",
|
|
targetFolder: "", // No move, just create
|
|
permissionMap: map[string]bool{"folder1:create": false},
|
|
expectSuccess: false,
|
|
expectedChecks: 1,
|
|
expectedError: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "Update resource in same folder",
|
|
initialFolder: "folder1",
|
|
targetFolder: "folder1", // Same folder, just update
|
|
permissionMap: map[string]bool{"folder1:update": true},
|
|
expectSuccess: true,
|
|
expectedChecks: 1,
|
|
expectedError: 0,
|
|
},
|
|
{
|
|
name: "Update resource denied",
|
|
initialFolder: "folder1",
|
|
targetFolder: "folder1", // Same folder
|
|
permissionMap: map[string]bool{"folder1:update": false},
|
|
expectSuccess: false,
|
|
expectedChecks: 1,
|
|
expectedError: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "Move resource to another folder - allowed",
|
|
initialFolder: "folder1",
|
|
targetFolder: "folder2", // Moving to a different folder
|
|
permissionMap: map[string]bool{"folder1:update": true, "folder2:create": true},
|
|
expectSuccess: true,
|
|
expectedChecks: 2, // Should check both folders
|
|
expectedError: 0,
|
|
},
|
|
{
|
|
name: "Move resource - source folder access denied",
|
|
initialFolder: "folder1",
|
|
targetFolder: "folder2",
|
|
permissionMap: map[string]bool{"folder1:update": false, "folder2:create": true},
|
|
expectSuccess: false,
|
|
expectedChecks: 1, // Should stop at first check
|
|
expectedError: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "Move resource - destination folder access denied",
|
|
initialFolder: "folder1",
|
|
targetFolder: "folder2",
|
|
permissionMap: map[string]bool{"folder1:update": true, "folder2:create": false},
|
|
expectSuccess: false,
|
|
expectedChecks: 2, // Should do both checks but fail on second
|
|
expectedError: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "Move resource - from empty folder to named folder",
|
|
initialFolder: "",
|
|
targetFolder: "folder1",
|
|
permissionMap: map[string]bool{":update": true, "folder1:create": true},
|
|
expectSuccess: true,
|
|
expectedChecks: 2,
|
|
expectedError: 0,
|
|
},
|
|
}
|
|
|
|
for i, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create a unique resource name for each test
|
|
resourceName := fmt.Sprintf("test-resource-%d", i)
|
|
resourceUID := fmt.Sprintf("test123-%d", i)
|
|
|
|
// Create a mock access client with the test case's permission map
|
|
checksPerformed := []types.CheckRequest{}
|
|
mockAccess := &mockAccessClient{
|
|
allowed: false, // Default to false
|
|
allowedMap: tc.permissionMap,
|
|
checkFn: func(req types.CheckRequest) {
|
|
checksPerformed = append(checksPerformed, req)
|
|
},
|
|
}
|
|
|
|
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
|
Backend: backend,
|
|
AccessClient: mockAccess,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ctx := types.WithAuthInfo(context.Background(), testUser)
|
|
|
|
key := &resourcepb.ResourceKey{
|
|
Group: "test.grafana.app",
|
|
Resource: "testresources",
|
|
Namespace: nsPrefix + "-ns1",
|
|
Name: resourceName,
|
|
}
|
|
|
|
if tc.targetFolder == "" {
|
|
// Create resource with unique name
|
|
resourceJSON := fmt.Sprintf(`{
|
|
"apiVersion": "test.grafana.app/v1",
|
|
"kind": "TestResource",
|
|
"metadata": {
|
|
"name": "%s",
|
|
"uid": "%s",
|
|
"namespace": "%s",
|
|
"annotations": {
|
|
"grafana.app/folder": "%s"
|
|
}
|
|
},
|
|
"spec": {
|
|
"title": "Test Resource %d"
|
|
}
|
|
}`, resourceName, resourceUID, nsPrefix+"-ns1", tc.initialFolder, i)
|
|
|
|
checksPerformed = []types.CheckRequest{}
|
|
created, err := server.Create(ctx, &resourcepb.CreateRequest{
|
|
Value: []byte(resourceJSON),
|
|
Key: key,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectSuccess {
|
|
require.Nil(t, created.Error)
|
|
require.True(t, created.ResourceVersion > 0)
|
|
} else {
|
|
require.NotNil(t, created.Error)
|
|
require.Equal(t, tc.expectedError, created.Error.Code)
|
|
}
|
|
|
|
require.Len(t, checksPerformed, tc.expectedChecks)
|
|
if len(checksPerformed) > 0 {
|
|
require.Equal(t, utils.VerbCreate, checksPerformed[0].Verb)
|
|
require.Equal(t, tc.initialFolder, checksPerformed[0].Folder)
|
|
}
|
|
} else {
|
|
// Create a resource first, then update/move it
|
|
initialResourceJSON := fmt.Sprintf(`{
|
|
"apiVersion": "test.grafana.app/v1",
|
|
"kind": "TestResource",
|
|
"metadata": {
|
|
"name": "%s",
|
|
"uid": "%s",
|
|
"namespace": "%s",
|
|
"annotations": {
|
|
"grafana.app/folder": "%s"
|
|
}
|
|
},
|
|
"spec": {
|
|
"title": "Test Resource %d"
|
|
}
|
|
}`, resourceName, resourceUID, nsPrefix+"-ns1", tc.initialFolder, i)
|
|
|
|
// Override permissions for initial creation to always succeed
|
|
mockAccess.allowed = true
|
|
created, err := server.Create(ctx, &resourcepb.CreateRequest{
|
|
Value: []byte(initialResourceJSON),
|
|
Key: key,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, created.Error)
|
|
|
|
// Now try the update/move with the configured permissions
|
|
targetResourceJSON := fmt.Sprintf(`{
|
|
"apiVersion": "test.grafana.app/v1",
|
|
"kind": "TestResource",
|
|
"metadata": {
|
|
"name": "%s",
|
|
"uid": "%s",
|
|
"namespace": "%s",
|
|
"annotations": {
|
|
"grafana.app/folder": "%s"
|
|
}
|
|
},
|
|
"spec": {
|
|
"title": "Test Resource %d Updated"
|
|
}
|
|
}`, resourceName, resourceUID, nsPrefix+"-ns1", tc.targetFolder, i)
|
|
|
|
mockAccess.allowed = false // Reset to use the map
|
|
checksPerformed = []types.CheckRequest{}
|
|
|
|
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
|
|
Key: key,
|
|
Value: []byte(targetResourceJSON),
|
|
ResourceVersion: created.ResourceVersion,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectSuccess {
|
|
require.Nil(t, updated.Error)
|
|
require.True(t, updated.ResourceVersion > created.ResourceVersion)
|
|
} else {
|
|
require.NotNil(t, updated.Error)
|
|
require.Equal(t, tc.expectedError, updated.Error.Code)
|
|
}
|
|
|
|
require.Len(t, checksPerformed, tc.expectedChecks)
|
|
|
|
// Verify the correct permission checks were made
|
|
if tc.initialFolder != tc.targetFolder && len(checksPerformed) >= 2 {
|
|
// This is a folder move operation
|
|
require.Equal(t, utils.VerbUpdate, checksPerformed[0].Verb)
|
|
require.Equal(t, tc.initialFolder, checksPerformed[0].Folder)
|
|
|
|
require.Equal(t, utils.VerbCreate, checksPerformed[1].Verb)
|
|
require.Equal(t, tc.targetFolder, checksPerformed[1].Folder)
|
|
} else if len(checksPerformed) > 0 {
|
|
// Regular update, no folder change
|
|
require.Equal(t, utils.VerbUpdate, checksPerformed[0].Verb)
|
|
require.Equal(t, tc.initialFolder, checksPerformed[0].Folder)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// runTestListTrashAccessControl tests the access control logic for ListTrash
|
|
func runTestListTrashAccessControl(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
|
|
// Create two different users
|
|
testUserA := &identity.StaticRequester{
|
|
Type: types.TypeUser,
|
|
Login: "testuserA",
|
|
UserID: 123,
|
|
UserUID: "u123",
|
|
OrgRole: identity.RoleAdmin,
|
|
IsGrafanaAdmin: true, // admin user
|
|
}
|
|
|
|
testUserB := &identity.StaticRequester{
|
|
Type: types.TypeUser,
|
|
Login: "testuserB",
|
|
UserID: 456,
|
|
UserUID: "u456",
|
|
OrgRole: identity.RoleEditor,
|
|
IsGrafanaAdmin: false, // non-admin user
|
|
}
|
|
|
|
mockAccess := &mockAccessClient{
|
|
allowed: true, // Allow regular access
|
|
compileFn: func(user types.AuthInfo, req types.ListRequest) types.ItemChecker {
|
|
return func(name, folder string) bool {
|
|
if req.Verb == utils.VerbSetPermissions {
|
|
if requester, ok := user.(identity.Requester); ok && requester.GetIsGrafanaAdmin() {
|
|
return true // Admin users can access trash
|
|
}
|
|
return false // Non-admin users cannot access trash
|
|
}
|
|
return false
|
|
}
|
|
},
|
|
}
|
|
|
|
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
|
Backend: backend,
|
|
AccessClient: mockAccess,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a resource and delete it with user A
|
|
ctxA := types.WithAuthInfo(context.Background(), testUserA)
|
|
|
|
raw := []byte(`{
|
|
"apiVersion": "playlist.grafana.app/v0alpha1",
|
|
"kind": "Playlist",
|
|
"metadata": {
|
|
"name": "trash-test-playlist",
|
|
"uid": "trash-xyz",
|
|
"namespace": "` + nsPrefix + `-trash-test",
|
|
"annotations": {
|
|
"grafana.app/repoName": "elsewhere",
|
|
"grafana.app/repoPath": "path/to/item",
|
|
"grafana.app/repoTimestamp": "2024-02-02T00:00:00Z"
|
|
}
|
|
},
|
|
"spec": {
|
|
"title": "trash test",
|
|
"interval": "5m",
|
|
"items": [
|
|
{
|
|
"type": "dashboard_by_uid",
|
|
"value": "vmie2cmWz"
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
|
|
key := &resourcepb.ResourceKey{
|
|
Group: "playlist.grafana.app",
|
|
Resource: "playlists",
|
|
Namespace: nsPrefix + "-trash-test",
|
|
Name: "trash-test-playlist",
|
|
}
|
|
|
|
// Create the resource with user A
|
|
created, err := server.Create(ctxA, &resourcepb.CreateRequest{
|
|
Value: raw,
|
|
Key: key,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, created.Error)
|
|
|
|
// Delete the resource with user A
|
|
deleted, err := server.Delete(ctxA, &resourcepb.DeleteRequest{
|
|
Key: key,
|
|
ResourceVersion: created.ResourceVersion,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, deleted.ResourceVersion > created.ResourceVersion)
|
|
|
|
// Test 1: Admin user (user A) should be able to list trash and see their own deleted resource
|
|
trashList, err := server.List(ctxA, &resourcepb.ListRequest{
|
|
Source: resourcepb.ListRequest_TRASH,
|
|
Options: &resourcepb.ListOptions{
|
|
Key: &resourcepb.ResourceKey{
|
|
Group: key.Group,
|
|
Resource: key.Resource,
|
|
Namespace: key.Namespace,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, trashList.Error)
|
|
require.Len(t, trashList.Items, 1, "Admin user should see the deleted resource in trash")
|
|
|
|
// Test 2: Non-admin user (user B) who didn't delete the resource should NOT see it in trash
|
|
ctxB := types.WithAuthInfo(context.Background(), testUserB)
|
|
trashListB, err := server.List(ctxB, &resourcepb.ListRequest{
|
|
Source: resourcepb.ListRequest_TRASH,
|
|
Options: &resourcepb.ListOptions{
|
|
Key: &resourcepb.ResourceKey{
|
|
Group: key.Group,
|
|
Resource: key.Resource,
|
|
Namespace: key.Namespace,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, trashListB.Error)
|
|
require.Len(t, trashListB.Items, 0, "Non-admin user who didn't delete the resource should not see it in trash")
|
|
|
|
// Test 3: Create and delete another resource with user B
|
|
keyB := &resourcepb.ResourceKey{
|
|
Group: "playlist.grafana.app",
|
|
Resource: "playlists",
|
|
Namespace: nsPrefix + "-trash-test",
|
|
Name: "trash-test-playlist-b",
|
|
}
|
|
|
|
rawB := []byte(`{
|
|
"apiVersion": "playlist.grafana.app/v0alpha1",
|
|
"kind": "Playlist",
|
|
"metadata": {
|
|
"name": "trash-test-playlist-b",
|
|
"uid": "trash-xyz-b",
|
|
"namespace": "` + nsPrefix + `-trash-test",
|
|
"annotations": {
|
|
"grafana.app/repoName": "elsewhere",
|
|
"grafana.app/repoPath": "path/to/item",
|
|
"grafana.app/repoTimestamp": "2024-02-02T00:00:00Z"
|
|
}
|
|
},
|
|
"spec": {
|
|
"title": "trash test b",
|
|
"interval": "5m",
|
|
"items": [
|
|
{
|
|
"type": "dashboard_by_uid",
|
|
"value": "vmie2cmWz"
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
|
|
// Create the resource with user B
|
|
createdB, err := server.Create(ctxB, &resourcepb.CreateRequest{
|
|
Value: rawB,
|
|
Key: keyB,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, createdB.Error)
|
|
|
|
// Delete the resource with user B
|
|
deletedB, err := server.Delete(ctxB, &resourcepb.DeleteRequest{
|
|
Key: keyB,
|
|
ResourceVersion: createdB.ResourceVersion,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, deletedB.ResourceVersion > createdB.ResourceVersion)
|
|
|
|
// Test 4: User B should see their own deleted resource in trash
|
|
trashListB2, err := server.List(ctxB, &resourcepb.ListRequest{
|
|
Source: resourcepb.ListRequest_TRASH,
|
|
Options: &resourcepb.ListOptions{
|
|
Key: &resourcepb.ResourceKey{
|
|
Group: keyB.Group,
|
|
Resource: keyB.Resource,
|
|
Namespace: keyB.Namespace,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, trashListB2.Error)
|
|
require.Len(t, trashListB2.Items, 1, "User should see their own deleted resource in trash")
|
|
|
|
// Test 5: Admin user should see both deleted resources
|
|
trashListA2, err := server.List(ctxA, &resourcepb.ListRequest{
|
|
Source: resourcepb.ListRequest_TRASH,
|
|
Options: &resourcepb.ListOptions{
|
|
Key: &resourcepb.ResourceKey{
|
|
Group: key.Group,
|
|
Resource: key.Resource,
|
|
Namespace: key.Namespace,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, trashListA2.Error)
|
|
require.Len(t, trashListA2.Items, 2, "Admin user should see all deleted resources in trash")
|
|
|
|
// Test 6: Verify the trash items have the correct metadata
|
|
for _, item := range trashListA2.Items {
|
|
var obj map[string]interface{}
|
|
err := json.Unmarshal(item.Value, &obj)
|
|
require.NoError(t, err)
|
|
|
|
// Check that the item has deletion timestamp
|
|
metadata, ok := obj["metadata"].(map[string]interface{})
|
|
require.True(t, ok, "Resource should have metadata")
|
|
require.NotNil(t, metadata["deletionTimestamp"], "Trash item should have deletion timestamp")
|
|
|
|
// Check that the item has the correct updatedBy field
|
|
annotations, ok := metadata["annotations"].(map[string]interface{})
|
|
require.True(t, ok, "Resource should have annotations")
|
|
require.Contains(t, annotations, "grafana.app/updatedBy", "Trash item should have updatedBy annotation")
|
|
}
|
|
}
|
|
|
|
// Mock access client for testing
|
|
type mockAccessClient struct {
|
|
allowed bool
|
|
allowedMap map[string]bool
|
|
checkFn func(types.CheckRequest)
|
|
compileFn func(user types.AuthInfo, req types.ListRequest) types.ItemChecker
|
|
}
|
|
|
|
func (m *mockAccessClient) Check(ctx context.Context, user types.AuthInfo, req types.CheckRequest) (types.CheckResponse, error) {
|
|
if m.checkFn != nil {
|
|
m.checkFn(req)
|
|
}
|
|
|
|
// Check specific folder:verb mappings if provided
|
|
if m.allowedMap != nil {
|
|
key := fmt.Sprintf("%s:%s", req.Folder, req.Verb)
|
|
if allowed, exists := m.allowedMap[key]; exists {
|
|
return types.CheckResponse{Allowed: allowed}, nil
|
|
}
|
|
}
|
|
|
|
return types.CheckResponse{Allowed: m.allowed}, nil
|
|
}
|
|
|
|
func (m *mockAccessClient) Compile(ctx context.Context, user types.AuthInfo, req types.ListRequest) (types.ItemChecker, error) {
|
|
if m.compileFn != nil {
|
|
return m.compileFn(user, req), nil
|
|
}
|
|
return func(name, folder string) bool {
|
|
key := fmt.Sprintf("%s:%s", folder, req.Verb)
|
|
if allowed, exists := m.allowedMap[key]; exists {
|
|
return allowed
|
|
}
|
|
return m.allowed
|
|
}, nil
|
|
}
|