diff --git a/pkg/api/api.go b/pkg/api/api.go index d5b4da005f0..cbfb05c299c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -52,9 +52,9 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/profile/switch-org/:id", reqSignedInNoAnonymous, hs.ChangeActiveOrgAndRedirectToHome) r.Get("/org/", reqOrgAdmin, hs.Index) r.Get("/org/new", reqGrafanaAdmin, hs.Index) - r.Get("/datasources/", reqOrgAdmin, hs.Index) - r.Get("/datasources/new", reqOrgAdmin, hs.Index) - r.Get("/datasources/edit/*", reqOrgAdmin, hs.Index) + r.Get("/datasources/", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead)), hs.Index) + r.Get("/datasources/new", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesCreate)), hs.Index) + r.Get("/datasources/edit/*", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead)), hs.Index) r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) @@ -266,18 +266,18 @@ func (hs *HTTPServer) registerRoutes() { // Data sources apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) { - datasourceRoute.Get("/", routing.Wrap(hs.GetDataSources)) - datasourceRoute.Post("/", quota("data_source"), bind(models.AddDataSourceCommand{}), routing.Wrap(AddDataSource)) - datasourceRoute.Put("/:id", bind(models.UpdateDataSourceCommand{}), routing.Wrap(hs.UpdateDataSource)) - datasourceRoute.Delete("/:id", routing.Wrap(hs.DeleteDataSourceById)) - datasourceRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDataSourceByUID)) - datasourceRoute.Delete("/name/:name", routing.Wrap(hs.DeleteDataSourceByName)) - datasourceRoute.Get("/:id", routing.Wrap(GetDataSourceById)) - datasourceRoute.Get("/uid/:uid", routing.Wrap(GetDataSourceByUID)) - datasourceRoute.Get("/name/:name", routing.Wrap(GetDataSourceByName)) - }, reqOrgAdmin) + datasourceRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead)), routing.Wrap(hs.GetDataSources)) + datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesCreate)), quota("data_source"), bind(models.AddDataSourceCommand{}), routing.Wrap(AddDataSource)) + datasourceRoute.Put("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesWrite, ScopeDatasourceID)), bind(models.UpdateDataSourceCommand{}), routing.Wrap(hs.UpdateDataSource)) + datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesDelete, ScopeDatasourceID)), routing.Wrap(hs.DeleteDataSourceById)) + datasourceRoute.Delete("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesDelete, ScopeDatasourceUID)), routing.Wrap(hs.DeleteDataSourceByUID)) + datasourceRoute.Delete("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesDelete, ScopeDatasourceName)), routing.Wrap(hs.DeleteDataSourceByName)) + datasourceRoute.Get("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceID)), routing.Wrap(GetDataSourceById)) + datasourceRoute.Get("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceUID)), routing.Wrap(GetDataSourceByUID)) + datasourceRoute.Get("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceName)), routing.Wrap(GetDataSourceByName)) + }) - apiRoute.Get("/datasources/id/:name", routing.Wrap(GetDataSourceIdByName), reqSignedIn) + apiRoute.Get("/datasources/id/:name", authorize(reqSignedIn, ac.EvalPermission(ActionDatasourcesIDRead, ScopeDatasourceName)), routing.Wrap(GetDataSourceIdByName)) apiRoute.Get("/plugins", routing.Wrap(hs.GetPluginList)) apiRoute.Get("/plugins/:pluginId/settings", routing.Wrap(hs.GetPluginSettingByID)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 657a5935012..12f2ab3d3b5 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -204,13 +204,14 @@ func (s *fakeRenderService) Init() error { func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []*accesscontrol.Permission) (*scenarioContext, *HTTPServer) { cfg.FeatureToggles = make(map[string]bool) cfg.FeatureToggles["accesscontrol"] = true + cfg.Quota.Enabled = false hs := &HTTPServer{ Cfg: cfg, + Bus: bus.GetBus(), + Live: newTestLive(t), + QuotaService: "a.QuotaService{Cfg: cfg}, RouteRegister: routing.NewRouteRegister(), - QuotaService: "a.QuotaService{ - Cfg: cfg, - }, AccessControl: accesscontrolmock.New().WithPermissions(permissions), } diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index a4b8311c71a..cde05b6fd2e 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -1,12 +1,18 @@ package api import ( + "bytes" "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -162,3 +168,334 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) } + +func TestAPI_Datasources_AccessControl(t *testing.T) { + testDatasource := models.DataSource{ + Id: 3, + Uid: "testUID", + OrgId: testOrgID, + Name: "test", + Url: "http://localhost:5432", + Type: "postgresql", + Access: "Proxy", + } + getDatasourceStub := func(query *models.GetDataSourceQuery) error { + result := testDatasource + result.Id = query.Id + result.OrgId = query.OrgId + query.Result = &result + return nil + } + getDatasourcesStub := func(cmd *models.GetDataSourcesQuery) error { + cmd.Result = []*models.DataSource{} + return nil + } + addDatasourceStub := func(cmd *models.AddDataSourceCommand) error { + cmd.Result = &testDatasource + return nil + } + updateDatasourceStub := func(cmd *models.UpdateDataSourceCommand) error { + cmd.Result = &testDatasource + return nil + } + deleteDatasourceStub := func(cmd *models.DeleteDataSourceCommand) error { + cmd.DeletedDatasourcesCount = 1 + return nil + } + addDatasourceBody := func() io.Reader { + s, _ := json.Marshal(models.AddDataSourceCommand{ + Name: "test", + Url: "http://localhost:5432", + Type: "postgresql", + Access: "Proxy", + }) + return bytes.NewReader(s) + } + updateDatasourceBody := func() io.Reader { + s, _ := json.Marshal(models.UpdateDataSourceCommand{ + Name: "test", + Url: "http://localhost:5432", + Type: "postgresql", + Access: "Proxy", + }) + return bytes.NewReader(s) + } + + type acTestCaseWithHandler struct { + busStubs []bus.HandlerFunc + body func() io.Reader + accessControlTestCase + } + tests := []acTestCaseWithHandler{ + { + busStubs: []bus.HandlerFunc{getDatasourcesStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesGet should return 200 for user with correct permissions", + url: "/api/datasources/", + method: http.MethodGet, + permissions: []*accesscontrol.Permission{{Action: ActionDatasourcesRead}}, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesGet should return 403 for user without required permissions", + url: "/api/datasources/", + method: http.MethodGet, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{addDatasourceStub}, + body: addDatasourceBody, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesPost should return 200 for user with correct permissions", + url: "/api/datasources/", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{{Action: ActionDatasourcesCreate}}, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesPost should return 403 for user without required permissions", + url: "/api/datasources/", + method: http.MethodPost, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub, updateDatasourceStub}, + body: updateDatasourceBody, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesPut should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/%v", testDatasource.Id), + method: http.MethodPut, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesWrite, + Scope: fmt.Sprintf("datasources:id:%v", testDatasource.Id), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesPut should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/%v", testDatasource.Id), + method: http.MethodPut, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub, deleteDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesDeleteByID should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/%v", testDatasource.Id), + method: http.MethodDelete, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesDelete, + Scope: fmt.Sprintf("datasources:id:%v", testDatasource.Id), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesDeleteByID should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/%v", testDatasource.Id), + method: http.MethodDelete, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub, deleteDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesDeleteByUID should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/uid/%v", testDatasource.Uid), + method: http.MethodDelete, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesDelete, + Scope: fmt.Sprintf("datasources:uid:%v", testDatasource.Uid), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesDeleteByUID should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/uid/%v", testDatasource.Uid), + method: http.MethodDelete, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub, deleteDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesDeleteByName should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/name/%v", testDatasource.Name), + method: http.MethodDelete, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesDelete, + Scope: fmt.Sprintf("datasources:name:%v", testDatasource.Name), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesDeleteByName should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/name/%v", testDatasource.Name), + method: http.MethodDelete, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesGetByID should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/%v", testDatasource.Id), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesRead, + Scope: fmt.Sprintf("datasources:id:%v", testDatasource.Id), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesGetByID should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/%v", testDatasource.Id), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesGetByUID should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/uid/%v", testDatasource.Uid), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesRead, + Scope: fmt.Sprintf("datasources:uid:%v", testDatasource.Uid), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesGetByUID should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/uid/%v", testDatasource.Uid), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesGetByName should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/name/%v", testDatasource.Name), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesRead, + Scope: fmt.Sprintf("datasources:name:%v", testDatasource.Name), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesGetByName should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/name/%v", testDatasource.Name), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + { + busStubs: []bus.HandlerFunc{getDatasourceStub}, + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusOK, + desc: "DatasourcesGetIdByName should return 200 for user with correct permissions", + url: fmt.Sprintf("/api/datasources/id/%v", testDatasource.Name), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{ + { + Action: ActionDatasourcesIDRead, + Scope: fmt.Sprintf("datasources:name:%v", testDatasource.Name), + }, + }, + }, + }, + { + accessControlTestCase: accessControlTestCase{ + expectedCode: http.StatusForbidden, + desc: "DatasourcesGetIdByName should return 403 for user without required permissions", + url: fmt.Sprintf("/api/datasources/id/%v", testDatasource.Name), + method: http.MethodGet, + permissions: []*accesscontrol.Permission{{Action: "wrong"}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Cleanup(bus.ClearBusHandlers) + for i, handler := range test.busStubs { + bus.AddHandler(fmt.Sprintf("test_handler_%v", i), handler) + } + + cfg := setting.NewCfg() + sc, hs := setupAccessControlScenarioContext(t, cfg, test.url, test.permissions) + + // Create a middleware to pretend user is logged in + pretendSignInMiddleware := func(c *models.ReqContext) { + sc.context = c + sc.context.UserId = testUserID + sc.context.OrgId = testOrgID + sc.context.Login = testUserLogin + sc.context.OrgRole = models.ROLE_VIEWER + sc.context.IsSignedIn = true + } + sc.m.Use(pretendSignInMiddleware) + + sc.resp = httptest.NewRecorder() + hs.SettingsProvider = &setting.OSSImpl{Cfg: cfg} + + var err error + if test.body != nil { + sc.req, err = http.NewRequest(test.method, test.url, test.body()) + sc.req.Header.Add("Content-Type", "application/json") + } else { + sc.req, err = http.NewRequest(test.method, test.url, nil) + } + assert.NoError(t, err) + + sc.exec() + assert.Equal(t, test.expectedCode, sc.resp.Code) + }) + } +} diff --git a/pkg/api/index.go b/pkg/api/index.go index 2b18c839377..cf65f7bfd31 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -253,7 +253,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto configNodes := []*dtos.NavLink{} - if c.OrgRole == models.ROLE_ADMIN { + if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead)) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Data sources", Icon: "database", diff --git a/pkg/api/roles.go b/pkg/api/roles.go index 12fc888ad6e..4dc3b898ab0 100644 --- a/pkg/api/roles.go +++ b/pkg/api/roles.go @@ -1,12 +1,19 @@ package api import ( + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" ) // API related actions const ( ActionProvisioningReload = "provisioning:reload" + + ActionDatasourcesRead = "datasources:read" + ActionDatasourcesCreate = "datasources:create" + ActionDatasourcesWrite = "datasources:write" + ActionDatasourcesDelete = "datasources:delete" + ActionDatasourcesIDRead = "datasources:id:read" ) // API related scopes @@ -16,26 +23,70 @@ const ( ScopeProvisionersPlugins = "provisioners:plugins" ScopeProvisionersDatasources = "provisioners:datasources" ScopeProvisionersNotifications = "provisioners:notifications" + + ScopeDatasourcesAll = `datasources:*` + ScopeDatasourceID = `datasources:id:{{ index . ":id" }}` + ScopeDatasourceUID = `datasources:uid:{{ index . ":uid" }}` + ScopeDatasourceName = `datasources:name:{{ index . ":name" }}` ) // declareFixedRoles declares to the AccessControl service fixed roles and their // grants to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" // that HTTPServer needs func (hs *HTTPServer) declareFixedRoles() error { - registration := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:provisioning:admin", - Description: "Reload provisioning configurations", - Permissions: []accesscontrol.Permission{ - { - Action: ActionProvisioningReload, - Scope: ScopeProvisionersAll, + registrations := []accesscontrol.RoleRegistration{ + { + Role: accesscontrol.RoleDTO{ + Version: 1, + Name: "fixed:provisioning:admin", + Description: "Reload provisioning configurations", + Permissions: []accesscontrol.Permission{ + { + Action: ActionProvisioningReload, + Scope: ScopeProvisionersAll, + }, + }, + }, + Grants: []string{accesscontrol.RoleGrafanaAdmin}, + }, + { + Role: accesscontrol.RoleDTO{ + Version: 1, + Name: "fixed:datasources:admin", + Description: "Gives access to create, read, update, delete datasources", + Permissions: []accesscontrol.Permission{ + { + Action: ActionDatasourcesRead, + Scope: ScopeDatasourcesAll, + }, + { + Action: ActionDatasourcesWrite, + Scope: ScopeDatasourcesAll, + }, + {Action: ActionDatasourcesCreate}, + { + Action: ActionDatasourcesDelete, + Scope: ScopeDatasourcesAll, + }, + }, + }, + Grants: []string{string(models.ROLE_ADMIN)}, + }, + { + Role: accesscontrol.RoleDTO{ + Version: 1, + Name: "fixed:datasources:id:viewer", + Description: "Gives access to read datasources ID", + Permissions: []accesscontrol.Permission{ + { + Action: ActionDatasourcesIDRead, + Scope: ScopeDatasourcesAll, + }, }, }, + Grants: []string{string(models.ROLE_VIEWER)}, }, - Grants: []string{accesscontrol.RoleGrafanaAdmin}, } - return hs.AccessControl.DeclareFixedRoles(registration) + return hs.AccessControl.DeclareFixedRoles(registrations...) } diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx index 84177721900..0c6791735a5 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx @@ -8,6 +8,7 @@ export interface Props { buttonIcon: IconName; buttonLink?: string; buttonTitle: string; + buttonDisabled?: boolean; onClick?: (event: MouseEvent) => void; proTip?: string; proTipLink?: string; @@ -31,6 +32,7 @@ const EmptyListCTA: React.FunctionComponent = ({ buttonIcon, buttonLink, buttonTitle, + buttonDisabled, onClick, proTip, proTipLink, @@ -79,6 +81,7 @@ const EmptyListCTA: React.FunctionComponent = ({ icon={buttonIcon} className={ctaElementClassName} aria-label={selectors.components.CallToActionCard.button(buttonTitle)} + disabled={buttonDisabled} > {buttonTitle} diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index c68713ce69f..5d2099ab807 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -5,7 +5,7 @@ import { LinkButton } from '@grafana/ui'; export interface Props { searchQuery: string; setSearchQuery: (value: string) => void; - linkButton?: { href: string; title: string }; + linkButton?: { href: string; title: string; disabled?: boolean }; target?: string; placeholder?: string; } @@ -13,7 +13,7 @@ export interface Props { export default class PageActionBar extends PureComponent { render() { const { searchQuery, linkButton, setSearchQuery, target, placeholder = 'Search by name or type' } = this.props; - const linkProps = { href: linkButton?.href }; + const linkProps = { href: linkButton?.href, disabled: linkButton?.disabled }; if (target) { (linkProps as any).target = target; diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx index bff292de3f3..975f925c8b3 100644 --- a/public/app/features/datasources/DataSourcesListPage.test.tsx +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -6,6 +6,14 @@ import { DataSourcesListPage, Props } from './DataSourcesListPage'; import { getMockDataSources } from './__mocks__/dataSourcesMocks'; import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const setup = (propOverrides?: object) => { const props: Props = { dataSources: [] as DataSourceSettings[], diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx index 142b2380bbe..85b2c458496 100644 --- a/public/app/features/datasources/DataSourcesListPage.tsx +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -1,6 +1,8 @@ // Libraries import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +// Services & Utils +import { contextSrv } from 'app/core/core'; // Components import Page from 'app/core/components/Page/Page'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; @@ -8,7 +10,7 @@ import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import DataSourcesList from './DataSourcesList'; // Types import { IconName } from '@grafana/ui'; -import { StoreState } from 'app/types'; +import { StoreState, AccessControlAction } from 'app/types'; // Actions import { loadDataSources } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; @@ -69,16 +71,23 @@ export class DataSourcesListPage extends PureComponent { hasFetched, } = this.props; + const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); const linkButton = { href: 'datasources/new', title: 'Add data source', + disabled: !canCreateDataSource, + }; + + const emptyList = { + ...emptyListModel, + buttonDisabled: !canCreateDataSource, }; return ( <> - {hasFetched && dataSourcesCount === 0 && } + {hasFetched && dataSourcesCount === 0 && } {hasFetched && dataSourcesCount > 0 && [ { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const setup = (propOverrides?: object) => { const props: Props = { isReadOnly: true, diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/settings/ButtonRow.tsx index 8b744f68667..387e1cd8bda 100644 --- a/public/app/features/datasources/settings/ButtonRow.tsx +++ b/public/app/features/datasources/settings/ButtonRow.tsx @@ -4,6 +4,9 @@ import { selectors } from '@grafana/e2e-selectors'; import config from 'app/core/config'; import { Button, LinkButton } from '@grafana/ui'; +import { AccessControlAction } from 'app/types/'; +import { contextSrv } from 'app/core/core'; + export interface Props { exploreUrl: string; isReadOnly: boolean; @@ -13,35 +16,39 @@ export interface Props { } const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit, onTest, exploreUrl }) => { + const canEditDataSources = !isReadOnly && contextSrv.hasPermission(AccessControlAction.DataSourcesWrite); + const canDeleteDataSources = !isReadOnly && contextSrv.hasPermission(AccessControlAction.DataSourcesDelete); + const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); + return (
Back - + Explore - {!isReadOnly && ( + {canEditDataSources && ( )} - {isReadOnly && ( + {!canEditDataSources && ( diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx index 6af2999f3a1..d4c846589d5 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -9,6 +9,14 @@ import { screen, render } from '@testing-library/react'; import { selectors } from '@grafana/e2e-selectors'; import { PluginState } from '@grafana/data'; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const getMockNode = () => ({ text: 'text', subTitle: 'subtitle', diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 220ced746ea..0d182eed22c 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -7,6 +7,8 @@ import BasicSettings from './BasicSettings'; import ButtonRow from './ButtonRow'; // Services & Utils import appEvents from 'app/core/app_events'; +import { contextSrv } from 'app/core/core'; + // Actions & selectors import { getDataSource, getDataSourceMeta } from '../state/selectors'; import { @@ -19,7 +21,7 @@ import { import { getNavModel } from 'app/core/selectors/navModel'; // Types -import { StoreState } from 'app/types/'; +import { StoreState, AccessControlAction } from 'app/types/'; import { DataSourceSettings, urlUtil } from '@grafana/data'; import { Alert, Button, LinkButton } from '@grafana/ui'; import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from '../state/navModel'; @@ -140,6 +142,14 @@ export class DataSourceSettingsPage extends PureComponent { ); } + renderMissingEditRightsMessage() { + return ( + + You are not allowed to modify this data source. Please contact your server admin to update this data source. + + ); + } + testDataSource() { const { dataSource, testDataSource } = this.props; testDataSource(dataSource.name); @@ -228,9 +238,11 @@ export class DataSourceSettingsPage extends PureComponent { renderSettings() { const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props; + const canEditDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite); return (
+ {!canEditDataSources && this.renderMissingEditRightsMessage()} {this.isReadOnly() && this.renderIsReadOnlyMessage()} {dataSourceMeta.state && (
diff --git a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap index 1c3741f48e8..068e473453e 100644 --- a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap +++ b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap @@ -12,6 +12,7 @@ exports[`Render should render component 1`] = ` Back { } const result = await getBackendSrv().post('/api/datasources', newInstance); + await updateFrontendSettings(); locationService.push(`/datasources/edit/${result.datasource.uid}`); }; } diff --git a/public/app/features/explore/NoDataSourceCallToAction.tsx b/public/app/features/explore/NoDataSourceCallToAction.tsx index 964a04c6c72..5916f4fb7c1 100644 --- a/public/app/features/explore/NoDataSourceCallToAction.tsx +++ b/public/app/features/explore/NoDataSourceCallToAction.tsx @@ -2,9 +2,14 @@ import React from 'react'; import { css } from '@emotion/css'; import { LinkButton, CallToActionCard, Icon, useTheme2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; + export const NoDataSourceCallToAction = () => { const theme = useTheme2(); + const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); + const message = 'Explore requires at least one data source. Once you have added a data source, you can query it here.'; const footer = ( @@ -23,7 +28,7 @@ export const NoDataSourceCallToAction = () => { ); const ctaElement = ( - + Add data source ); diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 7a6ed6decab..c6044d28069 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -30,6 +30,14 @@ import { Echo } from 'app/core/services/echo/Echo'; type Mock = jest.Mock; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + jest.mock('react-virtualized-auto-sizer', () => { return { __esModule: true, diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index 445ec2f34e8..dbae482b747 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -33,7 +33,12 @@ export enum AccessControlAction { LDAPUsersRead = 'ldap.user:read', LDAPUsersSync = 'ldap.user:sync', LDAPStatusRead = 'ldap.status:read', + DataSourcesExplore = 'datasources:explore', + DataSourcesRead = 'datasources:read', + DataSourcesCreate = 'datasources:create', + DataSourcesWrite = 'datasources:write', + DataSourcesDelete = 'datasources:delete', ActionServerStatsRead = 'server.stats:read', }