diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 68b63762ce6..08a3ed192b8 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -58,7 +58,7 @@ func newInternalIdentity(name string, namespace string, orgID int64) Requester { // This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with // static permissions so it can be used in legacy code paths. func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) { - r := newInternalIdentity(serviceName, "", orgID) + r := newInternalIdentity(serviceName, "*", orgID) return WithRequester(ctx, r), r } diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go new file mode 100644 index 00000000000..fffb01da544 --- /dev/null +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -0,0 +1,79 @@ +package dashboard + +import ( + "context" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" +) + +func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Authorizer { + return authorizer.AuthorizerFunc( + func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + // Note that we will return Allow more than expected. + // This is because we do NOT want to hit the RoleAuthorizer that would be evaluated afterwards. + + if !attr.IsResourceRequest() { + return authorizer.DecisionDeny, "unexpected non-resource request", nil + } + + user, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "error getting requester", err + } + + ns := attr.GetNamespace() + if ns == "" { + return authorizer.DecisionDeny, "expected namespace", nil + } + + info, err := types.ParseNamespace(attr.GetNamespace()) + if err != nil { + return authorizer.DecisionDeny, "error reading org from namespace", err + } + + // Validate organization access before we possibly step out here. + if user.GetOrgID() != info.OrgID { + return authorizer.DecisionDeny, "org mismatch", dashboards.ErrUserIsNotSignedInToOrg + } + + switch attr.GetVerb() { + case "list", "search": + // Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead)) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not read any dashboards", err + } + case "create": + // Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not create any dashboards", err + } + case "get": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not view dashboard", err + } + case "update", "patch": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not edit dashboard", err + } + case "delete": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not delete dashboard", err + } + default: + l.Info("unknown verb", "verb", attr.GetVerb()) + return authorizer.DecisionDeny, "unsupported verb", nil // Unknown verb + } + return authorizer.DecisionAllow, "", nil + }) +} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 90012b46ff5..e7067e82b95 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -122,11 +122,6 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyD rows: rows, a: a, history: query.GetHistory, - // This looks up rules from the permissions on a user - canReadDashboard: func(scopes ...string) bool { - return true // ??? - }, - // accesscontrol.Checker(user, dashboards.ActionDashboardsRead), }, err } @@ -138,8 +133,6 @@ type rowsWrapper struct { history bool count int - canReadDashboard func(scopes ...string) bool - // Current row *dashboardRow err error @@ -180,17 +173,6 @@ func (r *rowsWrapper) Next() bool { } if r.row != nil { - d := r.row - - // Access control checker - scopes := []string{dashboards.ScopeDashboardsProvider.GetResourceScopeUID(d.Dash.Name)} - if d.FolderUID != "" { // Copied from searchV2... not sure the logic is right - scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(d.FolderUID)) - } - if !r.canReadDashboard(scopes...) { - continue - } - // returns the first visible dashboard return true } diff --git a/pkg/registry/apis/dashboard/legacy/storage.go b/pkg/registry/apis/dashboard/legacy/storage.go index a51241a6410..89254fb0f2f 100644 --- a/pkg/registry/apis/dashboard/legacy/storage.go +++ b/pkg/registry/apis/dashboard/legacy/storage.go @@ -212,6 +212,12 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resource.Rea Code: http.StatusNotFound, } } else { + meta, err := utils.MetaAccessor(dash) + if err != nil { + rsp.Error = resource.AsErrorResult(err) + } + rsp.Folder = meta.GetFolder() + rsp.Value, err = json.Marshal(dash) if err != nil { rsp.Error = resource.AsErrorResult(err) diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go index 89390c18854..7fb9d54340e 100644 --- a/pkg/registry/apis/dashboard/legacy_storage.go +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -10,6 +10,8 @@ import ( "k8s.io/apiserver/pkg/registry/generic/registry" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -25,10 +27,11 @@ type DashboardStorage struct { DashboardService dashboards.DashboardService } -func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter, reg prometheus.Registerer, permissions dashboards.PermissionsRegistrationService) (grafanarest.Storage, error) { +func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter, reg prometheus.Registerer, permissions dashboards.PermissionsRegistrationService, ac types.AccessClient) (grafanarest.Storage, error) { server, err := resource.NewResourceServer(resource.ResourceServerOptions{ - Backend: s.Access, - Reg: reg, + Backend: s.Access, + Reg: reg, + AccessClient: ac, }) if err != nil { return nil, err diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index a2f4e524821..85a334dc405 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -12,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/kube-openapi/pkg/common" @@ -71,6 +72,7 @@ type DashboardsAPIBuilder struct { features featuremgmt.FeatureToggles accessControl accesscontrol.AccessControl + accessClient claims.AccessClient legacy *DashboardStorage unified resource.ResourceClient dashboardProvisioningService dashboards.DashboardProvisioningService @@ -97,6 +99,7 @@ func RegisterAPIService( provisioningDashboardService dashboards.DashboardProvisioningService, dashboardPermissions dashboards.PermissionsRegistrationService, accessControl accesscontrol.AccessControl, + accessClient claims.AccessClient, provisioning provisioning.ProvisioningService, dashStore dashboards.Store, reg prometheus.Registerer, @@ -121,6 +124,7 @@ func RegisterAPIService( dashboardPermissions: dashboardPermissions, features: features, accessControl: accessControl, + accessClient: accessClient, unified: unified, dashboardProvisioningService: provisioningDashboardService, search: NewSearchHandler(tracing, dual, legacyDashboardSearcher, unified, features), @@ -328,7 +332,16 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A } // Validate folder existence if specified and changed - if !a.IsDryRun() && newAccessor.GetFolder() != "" && newAccessor.GetFolder() != oldAccessor.GetFolder() { + if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() { + id, err := identity.GetRequester(ctx) + if err != nil { + return fmt.Errorf("error getting requester: %w", err) + } + + if err := b.verifyFolderAccessPermissions(ctx, id, newAccessor.GetFolder()); err != nil { + return err + } + if err := b.validateFolderExists(ctx, newAccessor.GetFolder(), nsInfo.OrgID); err != nil { return apierrors.NewNotFound(folders.FolderResourceInfo.GroupResource(), newAccessor.GetFolder()) } @@ -472,7 +485,7 @@ func (b *DashboardsAPIBuilder) storageForVersion( storage := map[string]rest.Storage{} apiGroupInfo.VersionedResourcesStorageMap[dashboards.GroupVersion().Version] = storage - legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, b.reg, b.dashboardPermissions) + legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, b.reg, b.dashboardPermissions, b.accessClient) if err != nil { return err } @@ -535,3 +548,24 @@ func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.API defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) return b.search.GetAPIRoutes(defs) } + +func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return GetAuthorizer(b.accessControl, b.log) +} + +func (b *DashboardsAPIBuilder) verifyFolderAccessPermissions(ctx context.Context, user identity.Requester, folderIds ...string) error { + scopes := []string{} + for _, folderId := range folderIds { + scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderId)) + } + ok, err := b.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, scopes...)) + if err != nil { + return err + } + + if !ok { + return dashboards.ErrFolderAccessDenied + } + + return nil +} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index f805197d292..e2d98a5bf9d 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -18,6 +18,7 @@ import ( authzv1 "github.com/grafana/authlib/authz/proto/v1" "github.com/grafana/authlib/cache" "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -553,10 +554,14 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, ctxLogger := s.logger.FromContext(ctx) // Only check action if the request doesn't specify scope - if req.Name == "" { + if req.Name == "" && req.Verb != utils.VerbCreate { return len(scopeMap) > 0, nil } + if req.Verb == utils.VerbCreate && req.ParentFolder == "" { + req.ParentFolder = accesscontrol.GeneralFolderUID + } + // Wildcard grant, no further checks needed if scopeMap["*"] { return true, nil @@ -568,7 +573,7 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, return false, status.Error(codes.NotFound, "unsupported resource") } - if scopeMap[t.scope(req.Name)] { + if req.Name != "" && scopeMap[t.scope(req.Name)] { return true, nil } diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index 660669e8ba0..62c06f0e910 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/authlib/cache" "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" @@ -127,19 +128,39 @@ func TestService_checkPermission(t *testing.T) { expected: true, }, { - name: "should return true if no resource is specified", + name: "should check general folder scope for root level resource creation", permissions: []accesscontrol.Permission{ { - Action: "folders:create", + Action: "dashboards:create", + Scope: "folders:uid:general", + Kind: "folders", + Attribute: "uid", + Identifier: "general", }, }, check: CheckRequest{ - Action: "folders:create", - Group: "folder.grafana.app", - Resource: "folders", + Action: "dashboards:create", + Group: "dashboard.grafana.app", + Resource: "dashboards", + Verb: utils.VerbCreate, }, expected: true, }, + { + name: "should fail if user doesn't have general folder scope for root level resource creation", + permissions: []accesscontrol.Permission{ + { + Action: "dashboards:create", + }, + }, + check: CheckRequest{ + Action: "dashboards:create", + Group: "dashboard.grafana.app", + Resource: "dashboards", + Verb: utils.VerbCreate, + }, + expected: false, + }, { name: "should return false if user has no permissions on resource", permissions: []accesscontrol.Permission{}, @@ -174,6 +195,72 @@ func TestService_checkPermission(t *testing.T) { }, expected: true, }, + { + name: "should allow creating a nested resource", + permissions: []accesscontrol.Permission{ + { + Action: "dashboards:create", + Scope: "folders:uid:parent", + Kind: "folders", + Attribute: "uid", + Identifier: "parent", + }, + }, + folders: []store.Folder{{UID: "parent"}}, + check: CheckRequest{ + Action: "dashboards:create", + Group: "dashboard.grafana.app", + Resource: "dashboards", + Name: "", + ParentFolder: "parent", + Verb: utils.VerbCreate, + }, + expected: true, + }, + { + name: "should deny creating a nested resource", + permissions: []accesscontrol.Permission{ + { + Action: "dashboards:create", + Scope: "folders:uid:parent", + Kind: "folders", + Attribute: "uid", + Identifier: "parent", + }, + }, + folders: []store.Folder{{UID: "parent"}}, + check: CheckRequest{ + Action: "dashboards:create", + Group: "dashboard.grafana.app", + Resource: "dashboards", + Name: "", + ParentFolder: "other_parent", + Verb: utils.VerbCreate, + }, + expected: false, + }, + { + name: "should allow if it's an any check", + permissions: []accesscontrol.Permission{ + { + Action: "dashboards:read", + Scope: "folders:uid:parent", + Kind: "folders", + Attribute: "uid", + Identifier: "parent", + }, + }, + folders: []store.Folder{{UID: "parent"}}, + check: CheckRequest{ + Action: "dashboards:read", + Group: "dashboard.grafana.app", + Resource: "dashboards", + Name: "", + ParentFolder: "", + Verb: utils.VerbList, + }, + expected: true, + }, } for _, tc := range testCases { diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 573242c998a..6b28d6b2a39 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -2,8 +2,10 @@ package integration import ( "context" + "encoding/json" "fmt" "net/http" + "strconv" "strings" "testing" @@ -14,10 +16,12 @@ import ( "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -41,8 +45,11 @@ type TestContext struct { EditorUser apis.User ViewerUser apis.User TestFolder *folder.Folder + AdminServiceAccount serviceaccounts.ServiceAccountDTO AdminServiceAccountToken string + EditorServiceAccount serviceaccounts.ServiceAccountDTO EditorServiceAccountToken string + ViewerServiceAccount serviceaccounts.ServiceAccountDTO ViewerServiceAccountToken string OrgID int64 } @@ -54,7 +61,7 @@ func TestIntegrationValidation(t *testing.T) { } // TODO: Skip mode3 - borken due to race conditions while setting default permissions across storage backends - dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode4, rest.Mode5} + dualWriterModes := []rest.DualWriterMode{rest.Mode0} for _, dualWriterMode := range dualWriterModes { t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { // Create a K8sTestHelper which will set up a real API server @@ -82,6 +89,7 @@ func testIntegrationValidationForServer(t *testing.T, helper *apis.K8sTestHelper // Create test contexts organization org1Ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) + org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode) t.Run("Organization 1 tests", func(t *testing.T) { t.Run("Dashboard validation tests", func(t *testing.T) { @@ -91,6 +99,27 @@ func testIntegrationValidationForServer(t *testing.T, helper *apis.K8sTestHelper t.Run("Dashboard quota tests", func(t *testing.T) { runQuotaTests(t, org1Ctx) }) + + t.Run("Authorization tests for all identity types", func(t *testing.T) { + runAuthorizationTests(t, org1Ctx) + }) + + t.Run("Dashboard permission tests", func(t *testing.T) { + runDashboardPermissionTests(t, org1Ctx) + }) + + t.Run("Dashboard LIST API test", func(t *testing.T) { + t.Skip("Skip LIST") + runDashboardListTest(t, org1Ctx) + }) + }) + + t.Run("Dashboard HTTP API test", func(t *testing.T) { + runDashboardHttpTest(t, org1Ctx, org2Ctx) + }) + + t.Run("Cross-organization tests", func(t *testing.T) { + runCrossOrgTests(t, org1Ctx, org2Ctx) }) } @@ -733,7 +762,7 @@ func runQuotaTests(t *testing.T, ctx TestContext) { // Helper function to create test context for an organization func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.OrgUsers, dualWriterMode rest.DualWriterMode) TestContext { // Create test folder - folderTitle := "Test Folder " + orgUsers.Admin.Identity.GetLogin() + folderTitle := "Test Folder Org " + strconv.FormatInt(orgUsers.Admin.Identity.GetOrgID(), 10) testFolder, err := createFolder(t, helper, orgUsers.Admin, folderTitle) require.NoError(t, err, "Failed to create test folder") @@ -745,8 +774,11 @@ func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.O EditorUser: orgUsers.Editor, ViewerUser: orgUsers.Viewer, TestFolder: testFolder, + AdminServiceAccount: orgUsers.AdminServiceAccount, AdminServiceAccountToken: orgUsers.AdminServiceAccountToken, + EditorServiceAccount: orgUsers.EditorServiceAccount, EditorServiceAccountToken: orgUsers.EditorServiceAccountToken, + ViewerServiceAccount: orgUsers.ViewerServiceAccount, ViewerServiceAccountToken: orgUsers.ViewerServiceAccountToken, OrgID: orgUsers.Admin.Identity.GetOrgID(), } @@ -774,7 +806,6 @@ func getResourceClient(t *testing.T, helper *apis.K8sTestHelper, user apis.User, } // Get a resource client for the specified service token -// nolint:unused func getServiceAccountResourceClient(t *testing.T, helper *apis.K8sTestHelper, token string, orgID int64, gvr schema.GroupVersionResource) *apis.K8sResourceClient { t.Helper() @@ -934,6 +965,7 @@ func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, databaseDash, err := client.Resource.Get(context.Background(), createdDash.GetName(), v1.GetOptions{}) if err != nil { t.Errorf("Potential caching issue: Unable to retrieve newly created dashboard: %v", err) + return nil, err } createdMeta, _ := utils.MetaAccessor(createdDash) @@ -971,3 +1003,1385 @@ func updateDashboard(t *testing.T, client *apis.K8sResourceClient, dashboard *un // Update the dashboard return client.Resource.Update(context.Background(), dashboard, v1.UpdateOptions{}) } + +// Run unified tests for different identity types (users and service tokens) +func runAuthorizationTests(t *testing.T, ctx TestContext) { + t.Helper() + + // Get clients for each identity type and role + adminUserClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + editorUserClient := getResourceClient(t, ctx.Helper, ctx.EditorUser, getDashboardGVR()) + viewerUserClient := getResourceClient(t, ctx.Helper, ctx.ViewerUser, getDashboardGVR()) + + adminTokenClient := getServiceAccountResourceClient(t, ctx.Helper, ctx.AdminServiceAccountToken, ctx.OrgID, getDashboardGVR()) + editorTokenClient := getServiceAccountResourceClient(t, ctx.Helper, ctx.EditorServiceAccountToken, ctx.OrgID, getDashboardGVR()) + viewerTokenClient := getServiceAccountResourceClient(t, ctx.Helper, ctx.ViewerServiceAccountToken, ctx.OrgID, getDashboardGVR()) + + // Define all identities to test + identities := []Identity{ + // User identities + {Name: "Admin user", DashboardClient: adminUserClient, Type: "user"}, + {Name: "Editor user", DashboardClient: editorUserClient, Type: "user"}, + {Name: "Viewer user", DashboardClient: viewerUserClient, Type: "user"}, + + // Token identities + {Name: "Admin token", DashboardClient: adminTokenClient, Type: "token"}, + {Name: "Editor token", DashboardClient: editorTokenClient, Type: "token"}, + {Name: "Viewer token", DashboardClient: viewerTokenClient, Type: "token"}, + } + + // Get admin clients for cleanup based on identity type + adminCleanupClients := map[string]*apis.K8sResourceClient{ + "user": adminUserClient, + "token": adminTokenClient, + } + + // Define test cases for different roles + type roleTest struct { + roleName string + canCreate bool + canUpdate bool + canDelete bool + } + + roleTests := []roleTest{ + { + roleName: "Admin", + canCreate: true, + canUpdate: true, + canDelete: true, + }, + { + roleName: "Editor", + canCreate: true, + canUpdate: true, + canDelete: true, + }, + { + roleName: "Viewer", + canCreate: false, + canUpdate: false, + canDelete: false, + }, + } + + // Create a map of identity client to role capabilities + authTests := make(map[*apis.K8sResourceClient]roleTest) + for _, identity := range identities { + for _, role := range roleTests { + if identity.Name == role.roleName+" "+identity.Type { + authTests[identity.DashboardClient] = role + break + } + } + } + + // Run tests for each identity type + for _, identity := range identities { + identity := identity // Capture range variable + t.Run(identity.Name, func(t *testing.T) { + // Get admin client for cleanup based on identity type + adminClient := adminCleanupClients[identity.Type] + + // Get role capabilities for this identity + roleCapabilities := authTests[identity.DashboardClient] + + // Test dashboard creation (both at root and in folder) + t.Run("dashboard creation", func(t *testing.T) { + // Test locations for dashboard creation + locations := []struct { + name string + folderUID string + }{ + {name: "at root", folderUID: ""}, + {name: "in folder", folderUID: ctx.TestFolder.UID}, + } + + for _, loc := range locations { + t.Run(loc.name, func(t *testing.T) { + if roleCapabilities.canCreate { + // Test can create dashboard + dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil) + require.NoError(t, err) + require.NotNil(t, dash) + + // Verify if dashboard was created in the correct folder + if loc.folderUID != "" { + meta, _ := utils.MetaAccessor(dash) + folderUID := meta.GetFolder() + require.Equal(t, loc.folderUID, folderUID, "Dashboard should be in the expected folder") + } + + // Clean up + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + } else { + // Test cannot create dashboard + _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil) + require.Error(t, err) + } + }) + } + }) + + // Test dashboard updates + t.Run("dashboard update", func(t *testing.T) { + // Create a dashboard with admin + dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil) + require.NoError(t, err) + require.NotNil(t, dash) + + if roleCapabilities.canUpdate { + // Test can update dashboard + updatedDash, err := updateDashboard(t, identity.DashboardClient, dash, "Updated by "+identity.Name, nil) + require.NoError(t, err) + require.NotNil(t, updatedDash) + + // Verify the update + meta, _ := utils.MetaAccessor(updatedDash) + require.Equal(t, "Updated by "+identity.Name, meta.FindTitle("")) + } else { + // Test cannot update dashboard + _, err := updateDashboard(t, identity.DashboardClient, dash, "Updated by "+identity.Name, nil) + require.Error(t, err) + } + + // Clean up + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + // Test dashboard deletion permissions + t.Run("dashboard deletion", func(t *testing.T) { + // Create a dashboard with admin + dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil) + require.NoError(t, err) + require.NotNil(t, dash) + + // Attempt to delete + err = identity.DashboardClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + if roleCapabilities.canDelete { + require.NoError(t, err, "Should be able to delete dashboard") + } else { + require.Error(t, err, "Should not be able to delete dashboard") + // Clean up with admin if the test identity couldn't delete + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + } + }) + + // TODO: Check if vieweing permission can be revoked as well. + // Test dashboard viewing for all roles + t.Run("dashboard viewing", func(t *testing.T) { + // Create a dashboard with admin + dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil) + require.NoError(t, err) + require.NotNil(t, dash) + + // Get the dashboard with the test identity + viewedDash, err := identity.DashboardClient.Resource.Get(context.Background(), dash.GetName(), v1.GetOptions{}) + require.NoError(t, err, "All identities should be able to view dashboards") + require.NotNil(t, viewedDash) + + // Clean up + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + }) + } +} + +// Run tests for dashboard permissions +func runDashboardPermissionTests(t *testing.T, ctx TestContext) { + t.Helper() + + // Get clients for each user + adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + editorClient := getResourceClient(t, ctx.Helper, ctx.EditorUser, getDashboardGVR()) + viewerClient := getResourceClient(t, ctx.Helper, ctx.ViewerUser, getDashboardGVR()) + + // Get folder clients + adminFolderClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getFolderGVR()) + + // Test custom dashboard permissions + t.Run("Dashboard with custom permissions", func(t *testing.T) { + // Create a dashboard with admin + dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil) + require.NoError(t, err) + require.NotNil(t, dash) + + // Get the dashboard ID + dashUID := dash.GetName() + + // Set permissions for the viewer to edit using HTTP API + setResourceUserPermission(t, ctx, ctx.AdminUser, true, dashUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) + + // Now the viewer should be able to update the dashboard + viewedDash, err := viewerClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{}) + require.NoError(t, err) + + // Update the dashboard with viewer (should succeed because of custom permissions) + updatedDash, err := updateDashboard(t, viewerClient, viewedDash, "Updated by Viewer with Permission", nil) + require.NoError(t, err) + require.NotNil(t, updatedDash) + + // Verify the update + meta, _ := utils.MetaAccessor(updatedDash) + require.Equal(t, "Updated by Viewer with Permission", meta.FindTitle("")) + + // Clean up + err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) + require.NoError(t, err) + }) + + // Test dashboard-specific permission overrides (new test case) + t.Run("Dashboard-specific permission overrides", func(t *testing.T) { + // Create multiple dashboards with admin + dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil) + require.NoError(t, err) + require.NotNil(t, dash1) + dash1UID := dash1.GetName() + + dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil) + require.NoError(t, err) + require.NotNil(t, dash2) + dash2UID := dash2.GetName() + + // Set EDIT permissions for the viewer on dash2 only + setResourceUserPermission(t, ctx, ctx.AdminUser, true, dash2UID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) + + // Verify viewer cannot edit dashboard1 (no custom permissions) + _, err = updateDashboard(t, viewerClient, dash1, "This should fail - no permissions", nil) + require.Error(t, err, "Viewer should not be able to update dashboard without permissions") + + // Verify viewer can edit dashboard2 (with custom permissions) + viewedDash2, err := viewerClient.Resource.Get(context.Background(), dash2UID, v1.GetOptions{}) + require.NoError(t, err) + + updatedDash2, err := updateDashboard(t, viewerClient, viewedDash2, "Updated by Viewer with Dashboard-Specific Permission", nil) + require.NoError(t, err) + require.NotNil(t, updatedDash2) + + // Verify the update + meta, _ := utils.MetaAccessor(updatedDash2) + require.Equal(t, "Updated by Viewer with Dashboard-Specific Permission", meta.FindTitle("")) + + // Also check viewer can delete the dashboard they have EDIT permission on + err = viewerClient.Resource.Delete(context.Background(), dash2UID, v1.DeleteOptions{}) + require.NoError(t, err, "Viewer should be able to delete dashboard with EDIT permission") + + // Clean up the other dashboard + err = adminClient.Resource.Delete(context.Background(), dash1UID, v1.DeleteOptions{}) + require.NoError(t, err) + }) + + // Test folder permissions inheritance + t.Run("Dashboard in folder with custom permissions", func(t *testing.T) { + // Create a new folder with the admin + customFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "Custom Permission Folder") + require.NoError(t, err, "Failed to create custom permission folder") + folderUID := customFolder.UID + + // Set permissions for the folder - give viewer edit access using HTTP API + setResourceUserPermission(t, ctx, ctx.AdminUser, false, folderUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) + + // Create a dashboard in the folder with admin + dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil) + require.NoError(t, err) + require.NotNil(t, dash) + + // Get the dashboard with viewer + viewedDash, err := viewerClient.Resource.Get(context.Background(), dash.GetName(), v1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, viewedDash) + + // Update the dashboard with viewer (should succeed because of folder permissions) + updatedDash, err := updateDashboard(t, viewerClient, viewedDash, "Updated by Viewer with Folder Permission", nil) + require.NoError(t, err) + require.NotNil(t, updatedDash) + + // Verify the update + meta, _ := utils.MetaAccessor(updatedDash) + require.Equal(t, "Updated by Viewer with Folder Permission", meta.FindTitle("")) + + // User should be able to create a dashboard in the folder + dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil) + require.NoError(t, err) + require.NotNil(t, dashViewer) + + // Revert granted permissions + setResourceUserPermission(t, ctx, ctx.AdminUser, false, folderUID, generateDefaultResourcePermissions(t)) + + // Clean up dashboard + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + err = viewerClient.Resource.Delete(context.Background(), dashViewer.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + + // Clean up the folder + err = adminFolderClient.Resource.Delete(context.Background(), folderUID, v1.DeleteOptions{}) + require.NoError(t, err) + }) + + // Test moving dashboard to folder without permission + t.Run("Cannot move dashboard to folder without permission", func(t *testing.T) { + // Create two folders with the admin + folder1, err := createFolder(t, ctx.Helper, ctx.AdminUser, "Default Permission Folder") + require.NoError(t, err, "Failed to create default permission folder") + folder1UID := folder1.UID + + folder2, err := createFolder(t, ctx.Helper, ctx.AdminUser, "Viewer Edit Permission Folder") + require.NoError(t, err, "Failed to create folder with viewer edit permissions") + folder2UID := folder2.UID + + // Set permissions for folder2 - give viewer edit access + setResourceUserPermission(t, ctx, ctx.AdminUser, false, folder2UID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) + + // Have the viewer create a dashboard in folder2 + viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil) + require.NoError(t, err, "Viewer should be able to create dashboard in folder with edit permissions") + require.NotNil(t, viewerDash) + dashUID := viewerDash.GetName() + + // Verify the dashboard has folder2UID set + meta, _ := utils.MetaAccessor(viewerDash) + folderUID := meta.GetFolder() + require.Equal(t, folder2UID, folderUID, "Dashboard should be in folder2") + + // Try to update the dashboard to move it to folder1 (where viewer has no edit permission) + meta.SetFolder(folder1UID) + + // This update should fail because viewer doesn't have edit permission in folder1 + _, err = viewerClient.Resource.Update(context.Background(), viewerDash, v1.UpdateOptions{}) + require.Error(t, err, "Viewer should not be able to move dashboard to folder without edit permission") + + // We're piggybacking onto this test to test if moving to a non existent folder also fails: + meta.SetFolder("non-existent-folder-uid") + _, err = viewerClient.Resource.Update(context.Background(), viewerDash, v1.UpdateOptions{}) + require.Error(t, err, "Viewer should not be able to move dashboard to non-existent folder") + _, err = adminClient.Resource.Update(context.Background(), viewerDash, v1.UpdateOptions{}) + require.Error(t, err, "Admin should not be able to move dashboard to non-existent folder") + + err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete dashboard") + err = adminFolderClient.Resource.Delete(context.Background(), folder1UID, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete folder1") + err = adminFolderClient.Resource.Delete(context.Background(), folder2UID, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete folder2") + }) + + // Test creator permissions (new test case) + t.Run("Creator of dashboard gets admin permission", func(t *testing.T) { + // Create a dashboard as an editor user (not admin) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil) + require.NoError(t, err) + require.NotNil(t, editorCreatedDash) + dashUID := editorCreatedDash.GetName() + + // Editor should be able to change permissions on their own dashboard (they get Admin permission as creator) + // Give viewer edit access to the dashboard + // Use the editor to set permissions (should succeed because creator has Admin permission) + setResourceUserPermission(t, ctx, ctx.EditorUser, true, dashUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) + + // Now verify the viewer can edit the dashboard + viewedDash, err := viewerClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{}) + require.NoError(t, err) + + updatedDash, err := updateDashboard(t, viewerClient, viewedDash, "Updated by Viewer with Permission from Editor", nil) + require.NoError(t, err) + require.NotNil(t, updatedDash) + + // Verify the update + meta, _ := utils.MetaAccessor(updatedDash) + require.Equal(t, "Updated by Viewer with Permission from Editor", meta.FindTitle("")) + + // Clean up + err = editorClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) + require.NoError(t, err, "Editor should be able to delete dashboard they created") + }) + + // Test scenario where admin restricts editor's access to dashboard they created + t.Run("Admin can override creator permissions", func(t *testing.T) { + t.Skip("Have to double check if that's actually the case") + // Create a dashboard as an editor user (not admin) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil) + require.NoError(t, err) + require.NotNil(t, editorCreatedDash) + dashUID := editorCreatedDash.GetName() + + // Verify editor can initially edit their dashboard (they have Admin permission as creator) + initialViewedDash, err := editorClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{}) + require.NoError(t, err) + + initialUpdatedDash, err := updateDashboard(t, editorClient, initialViewedDash, "Initial Update by Creator", nil) + require.NoError(t, err) + require.NotNil(t, initialUpdatedDash) + + // Admin restricts editor to view-only on their own dashboard + setResourceUserPermission(t, ctx, ctx.AdminUser, true, dashUID, addUserPermission(t, nil, ctx.EditorUser, ResourcePermissionLevelView)) + + // Now editor should NOT be able to edit the dashboard (admin override should succeed) + viewedDash, err := editorClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{}) + require.NoError(t, err) + + // Update attempt should fail + _, err = updateDashboard(t, editorClient, viewedDash, "This update should fail", nil) + require.Error(t, err, "Editor should not be able to update dashboard after admin restricts permissions") + + // Editor should also not be able to delete the dashboard + err = editorClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) + require.Error(t, err, "Editor should not be able to delete dashboard after admin restricts permissions") + + // Admin should be able to delete it + err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) + require.NoError(t, err, "Admin should always be able to delete dashboards") + }) + + // Test cross-org permissions + t.Run("Custom permissions don't extend across organizations", func(t *testing.T) { + // Get client for other org + otherOrgClient := getResourceClient(t, ctx.Helper, ctx.Helper.OrgB.Viewer, getDashboardGVR()) + + // Create a dashboard with admin in the current org + dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil) + require.NoError(t, err) + require.NotNil(t, dash) + org1DashUID := dash.GetName() + + // Set the highest permissions for the viewer in the current org + setResourceUserPermission(t, ctx, ctx.AdminUser, true, org1DashUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelAdmin)) + + // Verify the viewer in the current org can now view and update the dashboard + viewerDash, err := viewerClient.Resource.Get(context.Background(), org1DashUID, v1.GetOptions{}) + require.NoError(t, err, "Viewer with custom permissions should be able to view the dashboard") + + _, err = updateDashboard(t, viewerClient, viewerDash, "Updated by Viewer with Admin Permissions", nil) + require.NoError(t, err, "Viewer with admin permissions should be able to update the dashboard") + + // Try to access the dashboard from a viewer in the other org + _, err = otherOrgClient.Resource.Get(context.Background(), org1DashUID, v1.GetOptions{}) + require.Error(t, err, "User from other org should not be able to view dashboard even with custom permissions") + //statusErr := ctx.Helper.AsStatusError(err) + //require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code), "Should get 404 Not Found") + // TODO: Find out why this throws a 500 instead of a 404 with this message: + // an error on the server (\"Internal Server Error: \\\"/apis/dashboard.grafana.app/v1alpha1/namespaces/org-3/dashboards/test-cs6xk\\\": Dashboard not found\") has prevented the request from succeeding" + + // Clean up + err = adminClient.Resource.Delete(context.Background(), org1DashUID, v1.DeleteOptions{}) + require.NoError(t, err) + }) +} + +// Run tests specifically checking cross-org behavior +func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { + // Get clients for both organizations + org1SuperAdminClient := getResourceClient(t, org1Ctx.Helper, org1Ctx.AdminUser, getDashboardGVR()) + org1FolderClient := getResourceClient(t, org1Ctx.Helper, org1Ctx.AdminUser, getFolderGVR()) + + org2SuperAdminClient := getResourceClient(t, org2Ctx.Helper, org2Ctx.AdminUser, getDashboardGVR()) + org2FolderClient := getResourceClient(t, org2Ctx.Helper, org2Ctx.AdminUser, getFolderGVR()) + + // Org 1 users trying to access org2 + org1CrossEditorClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org1Ctx.EditorUser, + Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID), + GVR: getDashboardGVR(), + }) + org1CrossViewerClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org1Ctx.ViewerUser, + Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID), + GVR: getDashboardGVR(), + }) + org1CrossEditorTokenClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + ServiceAccountToken: org1Ctx.EditorServiceAccountToken, + Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID), + GVR: getDashboardGVR(), + }) + org1CrossViewerTokenClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + ServiceAccountToken: org1Ctx.ViewerServiceAccountToken, + Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID), + GVR: getDashboardGVR(), + }) + + // Org 2 users trying to access org1 + org2CrossEditorClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org2Ctx.EditorUser, + Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID), + GVR: getDashboardGVR(), + }) + org2CrossViewerClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org2Ctx.ViewerUser, + Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID), + GVR: getDashboardGVR(), + }) + org2CrossEditorTokenClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + ServiceAccountToken: org2Ctx.EditorServiceAccountToken, + Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID), + GVR: getDashboardGVR(), + }) + org2CrossViewerTokenClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + ServiceAccountToken: org2Ctx.ViewerServiceAccountToken, + Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID), + GVR: getDashboardGVR(), + }) + + // Test dashboard and folder name/UID uniqueness across orgs + t.Run("Dashboard and folder names/UIDs are unique per organization", func(t *testing.T) { + // Create dashboard with same UID in both orgs - should succeed + uid := "cross-org-dash-uid" + dashTitle := "Cross-Org Dashboard" + + // Create in org1 + dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid) + require.NoError(t, err, "Failed to create dashboard in org1") + + // Create in org2 with same UID - should succeed (UIDs only need to be unique within an org) + dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid) + require.NoError(t, err, "Failed to create dashboard with same UID in org2") + + // Verify both dashboards were created + require.Equal(t, uid, dash1.GetName(), "Dashboard UID in org1 should match") + require.Equal(t, uid, dash2.GetName(), "Dashboard UID in org2 should match") + + _, err = updateDashboard(t, org1SuperAdminClient, dash1, "Updated in org1", nil) + require.NoError(t, err, "Failed to update dashboard in org1") + + _, err = updateDashboard(t, org2SuperAdminClient, dash2, "Updated in org2", nil) + require.NoError(t, err, "Failed to update dashboard in org2") + + dash1updated, err := org1SuperAdminClient.Resource.Get(context.Background(), uid, v1.GetOptions{}) + require.NoError(t, err, "Failed to get updated dashboard in org1") + meta1, _ := utils.MetaAccessor(dash1updated) + require.Equal(t, "Updated in org1", meta1.FindTitle(""), "Dashboard title in org1 should be updated") + + dash2updated, err := org2SuperAdminClient.Resource.Get(context.Background(), uid, v1.GetOptions{}) + require.NoError(t, err, "Failed to get updated dashboard in org2") + meta2, _ := utils.MetaAccessor(dash2updated) + require.Equal(t, "Updated in org2", meta2.FindTitle(""), "Dashboard title in org2 should be updated") + + // Clean up + err = org1SuperAdminClient.Resource.Delete(context.Background(), uid, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete dashboard in org1") + + err = org2SuperAdminClient.Resource.Delete(context.Background(), uid, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete dashboard in org2") + + // Repeat test with folders + folderUID := "cross-org-folder-uid" + folderTitle := "Cross-Org Folder" + + // Create folder objects directly with fixed UIDs + folder1 := createFolderObject(t, folderTitle, org1Ctx.Helper.Namespacer(org1Ctx.OrgID), "") + meta1, err = utils.MetaAccessor(folder1) + require.NoError(t, err) + meta1.SetName(folderUID) + meta1.SetGenerateName("") + + folder2 := createFolderObject(t, folderTitle, org2Ctx.Helper.Namespacer(org2Ctx.OrgID), "") + meta2, err = utils.MetaAccessor(folder2) + require.NoError(t, err) + meta2.SetName(folderUID) + meta2.SetGenerateName("") + + // Create folders in both orgs + createdFolder1, err := org1FolderClient.Resource.Create(context.Background(), folder1, v1.CreateOptions{}) + require.NoError(t, err, "Failed to create folder in org1") + + createdFolder2, err := org2FolderClient.Resource.Create(context.Background(), folder2, v1.CreateOptions{}) + require.NoError(t, err, "Failed to create folder with same UID in org2") + + // Verify both folders were created with the same UID + require.Equal(t, folderUID, createdFolder1.GetName(), "Folder UID in org1 should match") + require.Equal(t, folderUID, createdFolder2.GetName(), "Folder UID in org2 should match") + + // Rename folders + _, err = updateDashboard(t, org1FolderClient, createdFolder1, "Updated folder in org1", nil) + require.NoError(t, err, "Failed to update folder in org1") + + _, err = updateDashboard(t, org2FolderClient, createdFolder2, "Updated folderin org2", nil) + require.NoError(t, err, "Failed to update folder in org2") + + folder1updated, err := org1FolderClient.Resource.Get(context.Background(), folderUID, v1.GetOptions{}) + require.NoError(t, err, "Failed to get updated folder in org1") + meta1, _ = utils.MetaAccessor(folder1updated) + require.Equal(t, "Updated folder in org1", meta1.FindTitle(""), "Folder title in org1 should be updated") + + folder2updated, err := org2FolderClient.Resource.Get(context.Background(), folderUID, v1.GetOptions{}) + require.NoError(t, err, "Failed to get updated folder in org2") + meta2, _ = utils.MetaAccessor(folder2updated) + require.Equal(t, "Updated folderin org2", meta2.FindTitle(""), "Folder title in org2 should be updated") + + // Clean up + err = org1FolderClient.Resource.Delete(context.Background(), folderUID, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete folder in org1") + + err = org2FolderClient.Resource.Delete(context.Background(), folderUID, v1.DeleteOptions{}) + require.NoError(t, err, "Failed to delete folder in org2") + }) + + // Test cross-organization access + t.Run("Cross-organization access", func(t *testing.T) { + // Create dashboards in both orgs + org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil) + require.NoError(t, err) + require.NotNil(t, org1Dashboard) + org1DashUID := org1Dashboard.GetName() + + org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil) + require.NoError(t, err) + require.NotNil(t, org2Dashboard) + org2DashUID := org2Dashboard.GetName() + + // Clean up at the end + defer func() { + err = org1SuperAdminClient.Resource.Delete(context.Background(), org1DashUID, v1.DeleteOptions{}) + require.NoError(t, err) + + err = org2SuperAdminClient.Resource.Delete(context.Background(), org2DashUID, v1.DeleteOptions{}) + require.NoError(t, err) + }() + + // Test org1 users trying to access org2 dashboard + testCrossOrgAccess := func(client *apis.K8sResourceClient, adminClient *apis.K8sResourceClient, targetDashUID string, description string) { + t.Run(description, func(t *testing.T) { + // Try to get the dashboard + _, err := client.Resource.Get(context.Background(), targetDashUID, v1.GetOptions{}) + require.Error(t, err, "Should not be able to access dashboard from another org") + //statusErr := org1Ctx.Helper.AsStatusError(err) + // TODO: Find out why this throws a 500 instead of a 404 with this message: + // "an error on the server (\"Internal Server Error: \\\"/apis/dashboard.grafana.app/v1alpha1/namespaces/default/dashboards/test-rbm2q\\\": Dashboard not found\") has prevented the request from succeeding" + //require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code), "Should get 404 Not Found") + + // Get a dashboard as admin from the target org to then send an update request + dash, err := adminClient.Resource.Get(context.Background(), targetDashUID, v1.GetOptions{}) + require.NoError(t, err) + + // Try to update the dashboard + _, err = updateDashboard(t, client, dash, "Renamed dashboard", nil) + require.Error(t, err, "Should not be able to update dashboard from another org") + + // Try to delete the dashboard + err = client.Resource.Delete(context.Background(), targetDashUID, v1.DeleteOptions{}) + require.Error(t, err, "Should not be able to delete dashboard from another org") + + // Verify that the rename and delete were not successful + dash, err = adminClient.Resource.Get(context.Background(), targetDashUID, v1.GetOptions{}) + require.NoError(t, err) + meta, _ := utils.MetaAccessor(dash) + require.NotEqual(t, "Renamed dashboard", meta.FindTitle(""), "Dashboard title should not be changed") + }) + } + + // Test real users from org1 trying to access org2 dashboard + testCrossOrgAccess(org1CrossEditorClient, org2SuperAdminClient, org2DashUID, "Org1 editor cannot access Org2 dashboard") + testCrossOrgAccess(org1CrossViewerClient, org2SuperAdminClient, org2DashUID, "Org1 viewer cannot access Org2 dashboard") + + // Test real users from org2 trying to access org1 dashboard + testCrossOrgAccess(org2CrossEditorClient, org1SuperAdminClient, org1DashUID, "Org2 editor cannot access Org1 dashboard") + testCrossOrgAccess(org2CrossViewerClient, org1SuperAdminClient, org1DashUID, "Org2 viewer cannot access Org1 dashboard") + + // Test service accounts from org1 trying to access org2 dashboard + testCrossOrgAccess(org1CrossEditorTokenClient, org2SuperAdminClient, org2DashUID, "Org1 editor token cannot access Org2 dashboard") + testCrossOrgAccess(org1CrossViewerTokenClient, org2SuperAdminClient, org2DashUID, "Org1 viewer token cannot access Org2 dashboard") + + // Test service accounts from org2 trying to access org1 dashboard + testCrossOrgAccess(org2CrossEditorTokenClient, org1SuperAdminClient, org1DashUID, "Org2 editor token cannot access Org1 dashboard") + testCrossOrgAccess(org2CrossViewerTokenClient, org1SuperAdminClient, org1DashUID, "Org2 viewer token cannot access Org1 dashboard") + }) +} + +type ResourcePermissionSetting struct { + Level ResourcePermissionLevel `json:"permission"` + + // Only set one of these! + Role *ResourcePermissionRole `json:"role,omitempty"` + UserID *int64 `json:"userId,omitempty"` + TeamID *int64 `json:"teamId,omitempty"` +} + +type ResourcePermissionLevel int + +const ( + ResourcePermissionLevelView ResourcePermissionLevel = 1 + ResourcePermissionLevelEdit ResourcePermissionLevel = 2 + ResourcePermissionLevelAdmin ResourcePermissionLevel = 4 +) + +type ResourcePermissionRole string + +const ( + ResourcePermissionRoleViewer ResourcePermissionRole = "Viewer" + ResourcePermissionRoleEditor ResourcePermissionRole = "Editor" +) + +func generateDefaultResourcePermissions(t *testing.T) []ResourcePermissionSetting { + t.Helper() + + viewerRole := ResourcePermissionRoleViewer + editorRole := ResourcePermissionRoleEditor + + return []ResourcePermissionSetting{ + { + Level: ResourcePermissionLevelView, + Role: &viewerRole, + }, + { + Level: ResourcePermissionLevelEdit, + Role: &editorRole, + }, + } +} + +func addUserPermission(t *testing.T, basePermissions *[]ResourcePermissionSetting, targetUser apis.User, level ResourcePermissionLevel) []ResourcePermissionSetting { + t.Helper() + + var permissions []ResourcePermissionSetting + if basePermissions == nil { + permissions = generateDefaultResourcePermissions(t) + } else { + permissions = *basePermissions + } + + userIdInt64, err := identity.UserIdentifier(targetUser.Identity.GetID()) + require.NoError(t, err) + + return append(permissions, ResourcePermissionSetting{ + Level: level, + UserID: &userIdInt64, + }) +} + +// Helper function to set permissions for a user via the HTTP API +func setResourceUserPermission(t *testing.T, ctx TestContext, actingUser apis.User, isDashboard bool, resourceUID string, permissions []ResourcePermissionSetting) { + t.Helper() + + // TODO: Use /apis once available + + type permissionRequest struct { + Items []ResourcePermissionSetting `json:"items"` + } + + reqBody := permissionRequest{ + Items: permissions, + } + + jsonBody, err := json.Marshal(reqBody) + require.NoError(t, err, "Failed to marshal permissions to JSON") + + // TODO: Use /apis once available + var path string + if isDashboard { + path = fmt.Sprintf("/api/dashboards/uid/%s/permissions", resourceUID) + } else { + path = fmt.Sprintf("/api/folders/%s/permissions", resourceUID) + } + + resp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: actingUser, + Method: http.MethodPost, + Path: path, + Body: jsonBody, + ContentType: "application/json", + }, &struct{}{}) + + // Check response status code + require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for %s", resourceUID) +} + +// Test creating a dashboard via HTTP and deleting it +func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestContext) { + t.Helper() + // Define test cases for locations and users + locationTestCases := []struct { + name string + folderUID string + }{ + { + name: "Root dashboard", + folderUID: "", + }, + { + name: "Folder dashboard", + folderUID: ctx.TestFolder.UID, + }, + } + + userTestCases := []struct { + name string + user apis.User + canCreate bool + canUpdate bool + canView bool + }{ + { + name: "Admin", + user: ctx.AdminUser, + canCreate: true, + canUpdate: true, + canView: true, + }, + { + name: "Editor", + user: ctx.EditorUser, + canCreate: true, + canUpdate: true, + canView: true, + }, + { + name: "Viewer", + user: ctx.ViewerUser, + canCreate: false, + canUpdate: false, + canView: true, + }, + { + name: "Foreign Org Admin", + user: foreignOrgCtx.AdminUser, + canCreate: false, + canUpdate: false, + canView: false, + }, + { + name: "Foreign Org Editor", + user: foreignOrgCtx.EditorUser, + canCreate: false, + canUpdate: false, + canView: false, + }, + { + name: "Foreign Org Viewer", + user: foreignOrgCtx.ViewerUser, + canCreate: false, + canUpdate: false, + canView: false, + }, + } + + // Test all combinations + for _, locTC := range locationTestCases { + for _, userTC := range userTestCases { + testName := fmt.Sprintf("%s by %s", locTC.name, userTC.name) + t.Run(testName, func(t *testing.T) { + // Create a unique dashboard UID - ensure it's 40 chars max + dashboardUID := fmt.Sprintf("test-%s-%s-%s", + "POST", + userTC.name[:3], // Use only first 3 chars of user role + util.GenerateShortUID()[:8]) // Use only first 8 chars of UID + dashboardTitle := fmt.Sprintf("Dashboard Created via %s - %s by %s", + "POST", locTC.name, userTC.name) + + // Construct the dashboard URL + dashboardPath := fmt.Sprintf("/apis/dashboard.grafana.app/v1alpha1/namespaces/%s/dashboards", ctx.Helper.Namespacer(ctx.OrgID)) + + // Create dashboard JSON with a single template + var metadata string + if locTC.folderUID != "" { + metadata = fmt.Sprintf(`"name": "%s", "annotations": {"grafana.app/folder": "%s", "grafana.app/grant-permissions": "default"}`, + dashboardUID, locTC.folderUID) + } else { + metadata = fmt.Sprintf(`"name": "%s", "annotations": {"grafana.app/grant-permissions": "default"}`, dashboardUID) + } + + dashboardJSON := fmt.Sprintf(`{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1alpha1", + "metadata": { + %s + }, + "spec": { + "title": "%s", + "schemaVersion": 41, + "layout": { + "kind": "GridLayout", + "items": [] + } + } + }`, metadata, dashboardTitle) + + // Make the request to create the dashboard + createResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: userTC.user, + Method: http.MethodPost, + Path: dashboardPath, + Body: []byte(dashboardJSON), + ContentType: "application/json", + }, &struct{}{}) + + // Check if the creation was successful or failed as expected + adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + + if userTC.canCreate { + require.Equal(t, http.StatusCreated, createResp.Response.StatusCode, + "Failed to %s dashboard as %s: %s", "POST", userTC.user.Identity.GetLogin(), createResp.Response.Status) + + // Construct the dashboard path with the actual UID for GET/DELETE + dashboardPath = fmt.Sprintf("/apis/dashboard.grafana.app/v1alpha1/namespaces/%s/dashboards/%s", + ctx.Helper.Namespacer(ctx.OrgID), dashboardUID) + + // Verify the dashboard was created by getting it via the admin client + dash, err := adminClient.Resource.Get(context.Background(), dashboardUID, v1.GetOptions{}) + require.NoError(t, err, "Failed to get dashboard after POST") + + // Verify the dashboard properties + meta, err := utils.MetaAccessor(dash) + require.NoError(t, err) + require.Equal(t, dashboardTitle, meta.FindTitle(""), "Dashboard title does not match") + + // Verify folder reference if applicable + if locTC.folderUID != "" { + require.Equal(t, locTC.folderUID, meta.GetFolder(), "Dashboard folder reference does not match") + } + + // Try to GET the dashboard with the test user + getResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: userTC.user, + Method: http.MethodGet, + Path: dashboardPath, + }, &struct{}{}) + + require.Equal(t, http.StatusOK, getResp.Response.StatusCode, + "User %s should be able to GET dashboard: %s", userTC.name, getResp.Response.Status) + + // Extract the dashboard object from the GET response + var dashObj map[string]interface{} + err = json.Unmarshal(getResp.Body, &dashObj) + require.NoError(t, err, "Failed to unmarshal dashboard JSON from GET") + + // Test both update methods for each user role + for _, updateUser := range userTestCases { + testDashboardHttpUpdateMethods(t, ctx, dashboardPath, dashboardTitle, updateUser.user, updateUser.canUpdate) + } + + // Verify whether every role can GET the dashboard that was created + for _, viewUser := range userTestCases { + roleGetResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: viewUser.user, + Method: http.MethodGet, + Path: dashboardPath, + }, &struct{}{}) + + if viewUser.canView { + require.Equal(t, http.StatusOK, roleGetResp.Response.StatusCode, + "User %s should be able to GET dashboard: %s", viewUser.name, roleGetResp.Response.Status) + } else { + require.NotEqual(t, http.StatusOK, roleGetResp.Response.StatusCode, + "User %s should not be able to GET dashboard: %s", viewUser.name, roleGetResp.Response.Status) + } + } + + // Delete the dashboard with DELETE request + deleteResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: userTC.user, + Method: http.MethodDelete, + Path: dashboardPath, + }, &struct{}{}) + + // Check response status code + require.Equal(t, http.StatusOK, deleteResp.Response.StatusCode, + "Failed to DELETE dashboard: %s", deleteResp.Response.Status) + + // Verify the dashboard was deleted + _, err = adminClient.Resource.Get(context.Background(), dashboardUID, v1.GetOptions{}) + //require.ErrorIs(t, err, dashboards.ErrDashboardNotFound, "Dashboard should be deleted") + require.Error(t, err, "Dashboard should be deleted") + } else { + require.NotEqual(t, http.StatusCreated, createResp.Response.StatusCode, + "%s should not be able to create dashboard via %s", userTC.name, "POST") + + // Always verify the dashboard wasn't created by checking for its UID + // Verify the dashboard was not created + _, err := adminClient.Resource.Get(context.Background(), dashboardUID, v1.GetOptions{}) + //require.ErrorIs(t, err, dashboards.ErrDashboardNotFound, "Dashboard should never have been created") + require.Error(t, err, "Dashboard should never have been created") + } + }) + } + } +} + +// Helper function to retrieve a dashboard via HTTP +func getDashboardViaHTTP(t *testing.T, ctx *TestContext, dashboardPath string, user apis.User) (map[string]interface{}, error) { + getResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: user, + Method: http.MethodGet, + Path: dashboardPath, + }, &struct{}{}) + + if getResp.Response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get dashboard: %s", getResp.Response.Status) + } + + var dashObj map[string]interface{} + err := json.Unmarshal(getResp.Body, &dashObj) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal dashboard JSON: %v", err) + } + + return dashObj, nil +} + +// Helper function to test dashboard updates via different http methods +func testDashboardHttpUpdateMethods(t *testing.T, ctx TestContext, dashboardPath string, originalTitle string, + updateUser apis.User, canUpdate bool) { + // Helper to verify update results based on permissions + verifyUpdateResults := func(updateMethod string, updateTitle string, updateResp apis.K8sResponse[struct{}]) { + if canUpdate { + require.Equal(t, http.StatusOK, updateResp.Response.StatusCode, + "Failed to update dashboard with %s as %s: %s", + updateMethod, updateUser.Identity.GetLogin(), updateResp.Response.Status) + + // Verify update by getting fresh dashboard state + updatedDashObj, err := getDashboardViaHTTP(t, &ctx, dashboardPath, ctx.AdminUser) + require.NoError(t, err, "Failed to get dashboard after update") + + // Extract title from the updated dashboard + updatedTitle := updatedDashObj["spec"].(map[string]interface{})["title"].(string) + require.Equal(t, updateTitle, updatedTitle, + "Dashboard title not updated via %s", updateMethod) + } else { + require.NotEqual(t, http.StatusOK, updateResp.Response.StatusCode, + "%s should not be able to update dashboard via %s", + updateUser.Identity.GetLogin(), updateMethod) + } + } + + // Test PUT update + t.Run(fmt.Sprintf("Update via %s by %s", "PUT", updateUser.Identity.GetLogin()), func(t *testing.T) { + updateTitle := fmt.Sprintf("%s - Updated via PUT by %s", originalTitle, updateUser.Identity.GetLogin()) + + // Always get fresh dashboard state via HTTP + // Use admin to ensure we can always retrieve it + freshDashObj, err := getDashboardViaHTTP(t, &ctx, dashboardPath, ctx.AdminUser) + require.NoError(t, err, "Failed to get fresh dashboard for update") + + // Modify title for PUT using fresh dashboard object + specMap := freshDashObj["spec"].(map[string]interface{}) + specMap["title"] = updateTitle + freshDashObj["spec"] = specMap + + // Convert to JSON + updatedJSON, err := json.Marshal(freshDashObj) + require.NoError(t, err, "Failed to marshal dashboard JSON") + + // Make PUT request + updateResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: updateUser, + Method: http.MethodPut, + Path: dashboardPath, + Body: updatedJSON, + ContentType: "application/json", + }, &struct{}{}) + + verifyUpdateResults("PUT", updateTitle, updateResp) + }) + + // Test PATCH update + t.Run(fmt.Sprintf("Update via %s by %s", "PATCH", updateUser.Identity.GetLogin()), func(t *testing.T) { + updateTitle := fmt.Sprintf("%s - Updated via PATCH by %s", originalTitle, updateUser.Identity.GetLogin()) + + // Create a JSON patch document + patchJSON := fmt.Sprintf(`[ + {"op": "replace", "path": "/spec/title", "value": "%s"} + ]`, updateTitle) + + // Make PATCH request + updateResp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: updateUser, + Method: http.MethodPatch, + Path: dashboardPath, + Body: []byte(patchJSON), + ContentType: "application/json-patch+json", + }, &struct{}{}) + + verifyUpdateResults("PATCH", updateTitle, updateResp) + }) +} + +// Test dashboard list API with complex permission scenarios +func runDashboardListTest(t *testing.T, ctx TestContext) { + t.Helper() + + // Make sure no dashboards exist before we start + adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + dashList, err := adminClient.Resource.List(context.Background(), v1.ListOptions{}) + require.NoError(t, err) + if len(dashList.Items) > 0 { + for _, dash := range dashList.Items { + t.Logf("Found dashboard: %s", dash.GetName()) + } + t.Fatalf("Expected no dashboards to exist, but found %d", len(dashList.Items)) + } + require.Equal(t, 0, len(dashList.Items), "Expected no dashboards to exist") + + // Also check that no folders exist + adminFolderClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getFolderGVR()) + folderList, err := adminFolderClient.Resource.List(context.Background(), v1.ListOptions{}) + require.NoError(t, err) + if len(folderList.Items) != 1 { + for _, folder := range folderList.Items { + t.Logf("Found folder: %s", folder.GetName()) + } + t.Fatalf("Expected 1 folder to exist, but found %d", len(folderList.Items)) + } + + // Define a map of user types to their clients + clients := map[string]struct { + userClient *apis.K8sResourceClient + tokenClient *apis.K8sResourceClient + folderClient *apis.K8sResourceClient + }{ + "Admin": { + userClient: getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()), + folderClient: getResourceClient(t, ctx.Helper, ctx.AdminUser, getFolderGVR()), + tokenClient: getServiceAccountResourceClient(t, ctx.Helper, ctx.AdminServiceAccountToken, ctx.OrgID, getDashboardGVR()), + }, + "Editor": { + userClient: getResourceClient(t, ctx.Helper, ctx.EditorUser, getDashboardGVR()), + folderClient: getResourceClient(t, ctx.Helper, ctx.EditorUser, getFolderGVR()), + tokenClient: getServiceAccountResourceClient(t, ctx.Helper, ctx.EditorServiceAccountToken, ctx.OrgID, getDashboardGVR()), + }, + "Viewer": { + userClient: getResourceClient(t, ctx.Helper, ctx.ViewerUser, getDashboardGVR()), + folderClient: getResourceClient(t, ctx.Helper, ctx.ViewerUser, getFolderGVR()), + tokenClient: getServiceAccountResourceClient(t, ctx.Helper, ctx.ViewerServiceAccountToken, ctx.OrgID, getDashboardGVR()), + }, + } + + // Define identities for testing LIST operation + identities := make([]Identity, 0, len(clients)*2+1) + for role, c := range clients { + identities = append(identities, + Identity{Name: role + " user", DashboardClient: c.userClient, FolderClient: c.folderClient, Type: "user"}, + Identity{Name: role + " token", DashboardClient: c.tokenClient, FolderClient: c.folderClient, Type: "token"}) + } + + // Define permission schemes with role/user access mapping + type accessConfig struct { + admin bool + editor bool + viewer bool + } + + // Create 5 folders with different permission schemes + folderConfigs := []struct { + name string + permissions func(t *testing.T, ctx TestContext, resourceUID string, isDashboard bool) + access accessConfig + }{ + { + name: "Admin only", + permissions: func(t *testing.T, ctx TestContext, resourceUID string, isDashboard bool) { + permissions := []ResourcePermissionSetting{} + setResourceUserPermission(t, ctx, ctx.AdminUser, isDashboard, resourceUID, permissions) + }, + access: accessConfig{admin: true}, + }, + { + name: "Admin and Editor", + permissions: func(t *testing.T, ctx TestContext, resourceUID string, isDashboard bool) { + editorRole := ResourcePermissionRoleEditor + permissions := []ResourcePermissionSetting{ + {Level: ResourcePermissionLevelEdit, Role: &editorRole}, + } + setResourceUserPermission(t, ctx, ctx.AdminUser, isDashboard, resourceUID, permissions) + }, + access: accessConfig{admin: true, editor: true}, + }, + { + name: "Default permissions", + permissions: func(t *testing.T, ctx TestContext, resourceUID string, isDashboard bool) { + // Default permissions - no need to modify + }, + access: accessConfig{admin: true, editor: true, viewer: true}, + }, + { + name: "Viewer user specific", + permissions: func(t *testing.T, ctx TestContext, resourceUID string, isDashboard bool) { + viewerUserId, _ := identity.UserIdentifier(ctx.ViewerUser.Identity.GetID()) + viewerServiceUserId := ctx.ViewerServiceAccount.Id + permissions := []ResourcePermissionSetting{ + {Level: ResourcePermissionLevelEdit, UserID: &viewerUserId}, + {Level: ResourcePermissionLevelEdit, UserID: &viewerServiceUserId}, + } + setResourceUserPermission(t, ctx, ctx.AdminUser, isDashboard, resourceUID, permissions) + }, + access: accessConfig{admin: true, viewer: true}, + }, + { + name: "Editor user specific", + permissions: func(t *testing.T, ctx TestContext, resourceUID string, isDashboard bool) { + editorUserId, _ := identity.UserIdentifier(ctx.EditorUser.Identity.GetID()) + editorServiceUserId := ctx.EditorServiceAccount.Id + permissions := []ResourcePermissionSetting{ + {Level: ResourcePermissionLevelView, UserID: &editorUserId}, + {Level: ResourcePermissionLevelView, UserID: &editorServiceUserId}, + } + setResourceUserPermission(t, ctx, ctx.AdminUser, isDashboard, resourceUID, permissions) + }, + access: accessConfig{admin: true, editor: true}, + }, + } + + // Create dashboards and folders with permissions + rootDashboards := make([]*unstructured.Unstructured, len(folderConfigs)) + folders := make([]*folder.Folder, len(folderConfigs)) + folderDashboards := make([]*unstructured.Unstructured, len(folderConfigs)) + + // Create all test resources (folders, dashboards) in one loop + for i, fc := range folderConfigs { + // Create root dashboard + rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil) + require.NoError(t, err) + rootDashboards[i] = rootDash + fc.permissions(t, ctx, rootDash.GetName(), true) + + // Create folder + folder, err := createFolder(t, ctx.Helper, ctx.AdminUser, fc.name+" folder") + require.NoError(t, err) + folders[i] = folder + fc.permissions(t, ctx, folder.UID, false) + + // Create dashboard in folder + folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil) + require.NoError(t, err) + folderDashboards[i] = folderDash + } + + folderPermissions := map[string][]string{ + "Admin user": { + "Default permissions folder", + "Editor user specific folder", + "Admin and Editor folder", + "Viewer user specific folder", + "Admin only folder", + "Test Folder Org 1", + }, + "Admin token": { + "Default permissions folder", + "Editor user specific folder", + "Admin and Editor folder", + "Viewer user specific folder", + "Admin only folder", + "Test Folder Org 1", + }, + "Editor user": { + "Default permissions folder", + "Editor user specific folder", + "Admin and Editor folder", + "Test Folder Org 1", + }, + "Editor token": { + "Default permissions folder", + "Editor user specific folder", + "Admin and Editor folder", + "Test Folder Org 1", + }, + "Viewer user": { + "Default permissions folder", + "Viewer user specific folder", + "Test Folder Org 1", + }, + "Viewer token": { + "Default permissions folder", + "Viewer user specific folder", + "Test Folder Org 1", + }, + } + + // Generate expectations based on folderConfigs access rules + expectations := make(map[string][]string) + for _, ident := range identities { + var expectedDashboards []string + + for _, fc := range folderConfigs { + // Check if this identity has access based on its role + hasAccess := false + roleName := strings.Split(ident.Name, " ")[0] // Extract "Admin", "Editor", or "Viewer" + + switch roleName { + case "Admin": + hasAccess = fc.access.admin + case "Editor": + hasAccess = fc.access.editor + case "Viewer": + hasAccess = fc.access.viewer + } + + if hasAccess { + // Add both root dashboard and folder dashboard to expectations + expectedDashboards = append(expectedDashboards, + fmt.Sprintf("Root Dashboard - %s", fc.name), + fmt.Sprintf("Dashboard in %s folder", fc.name)) + } + } + expectations[ident.Name] = expectedDashboards + } + + // Test LIST operation for each identity + for _, identity := range identities { + t.Run(fmt.Sprintf("LIST operation for %s", identity.Name), func(t *testing.T) { + // Get dashboards visible to this identity + clients := []apis.K8sResourceClient{ + *identity.DashboardClient, + *identity.FolderClient, + } + + for _, client := range clients { + t.Run(fmt.Sprintf("LIST operation for %s", client.Args.GVR), func(t *testing.T) { + listOpts := v1.ListOptions{} + dashList, err := client.Resource.List(context.Background(), listOpts) + require.NoError(t, err) + require.NotEmpty(t, dashList.Items) + + // Extract dashboard titles + dashTitles := make([]string, 0, len(dashList.Items)) + for _, dash := range dashList.Items { + meta, err := utils.MetaAccessor(&dash) + require.NoError(t, err) + + dashTitles = append(dashTitles, meta.FindTitle("")) + } + + // Verify expectations + var expectedTitles []string + if client.Args.GVR == getDashboardGVR() { + expectedTitles = expectations[identity.Name] + } else { + expectedTitles = folderPermissions[identity.Name] + } + require.ElementsMatch(t, expectedTitles, dashTitles) + + // Verify all expected items are found + for _, expected := range expectedTitles { + found := false + for _, title := range dashTitles { + if title == expected { + found = true + break + } + } + require.True(t, found, "%s should see dashboard '%s' but didn't", identity.Name, expected) + } + }) + } + }) + } + + // Clean up + t.Run("Cleanup dashboards and folders", func(t *testing.T) { + // Delete all root dashboards + for _, dash := range rootDashboards { + err := adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + } + + // Delete all folder dashboards and folders + for i, folder := range folders { + err := adminClient.Resource.Delete(context.Background(), folderDashboards[i].GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + + err = adminFolderClient.Resource.Delete(context.Background(), folder.UID, v1.DeleteOptions{}) + require.NoError(t, err) + } + }) +} diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index f16dfd7706f..68968c502fa 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -10,6 +10,9 @@ grafanaAPIServer=true queryLibrary=true queryService=true +[environment] +stack_id = 12345 + [plugins] allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,grafana-extensionexample3-app,grafana-e2etest-datasource