diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d16e816f3d..5916f960e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 5.0.0-stable (2018-03-01) + +### Fixes + +- **oauth** Fix Github OAuth not working with private Organizations [#11028](https://github.com/grafana/grafana/pull/11028) [@lostick](https://github.com/lostick) +- **kiosk** white area over bottom panels in kiosk mode [#11010](https://github.com/grafana/grafana/issues/11010) +- **alerting** Fix OK state doesn't show up in Microsoft Teams [#11032](https://github.com/grafana/grafana/pull/11032), thx [@manacker](https://github.com/manacker) + +# 5.0.0-beta5 (2018-02-26) + +### Fixes + +- **Orgs** Unable to switch org when too many orgs listed [#10774](https://github.com/grafana/grafana/issues/10774) +- **Folders** Make it easier/explicit to access/modify folders using the API [#10630](https://github.com/grafana/grafana/issues/10630) +- **Dashboard** Scrollbar works incorrectly in Grafana 5.0 Beta4 in some cases [#10982](https://github.com/grafana/grafana/issues/10982) +- **ElasticSearch** Custom aggregation sizes no longer allowed for Elasticsearch [#10124](https://github.com/grafana/grafana/issues/10124) +- **oauth** Github OAuth with allowed organizations fails to login [#10964](https://github.com/grafana/grafana/issues/10964) +- **heatmap** Heatmap panel has partially hidden legend [#10793](https://github.com/grafana/grafana/issues/10793) +- **snapshots** Expired snapshots not being cleaned up [#10996](https://github.com/grafana/grafana/pull/10996) + # 5.0.0-beta4 (2018-02-19) ### Fixes diff --git a/ROADMAP.md b/ROADMAP.md index 479c1933bc0..c8dc3186c73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,25 +1,28 @@ -# Roadmap (2017-10-31) +# Roadmap (2018-02-22) This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. But it will give you an idea of our current vision and plan. -### Short term (1-4 months) +### Short term (1-2 months) - - Release Grafana v5 - - Teams - - Dashboard folders - - Dashboard & folder permissions (assigned to users or groups) - - New Dashboard layout engine - - New sidemenu & nav UX +- v5.1 + - Crossplatform builds & build speed improvements + - Enterprise LDAP + - New template interpolation syntax + - Provisioning workflow + - First login registration view + - IFQL Initial support + +### Mid term (2-4 months) + +- v5.2 + - Azure monitor backend rewrite - Elasticsearch alerting - - React migration foundation (core components) - - Graphite 1.1 Tags Support + - Backend plugins? (alert notifiers, auth) ### Long term (4 - 8 months) -- Backend plugins to support more Auth options, Alerting data sources & notifications - Alerting improvements (silence, per series tracking, etc) -- Dashboard as configuration and other automation / provisioning improvements - Progress on React migration - Change visualization (panel type) on the fly. - Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) diff --git a/conf/defaults.ini b/conf/defaults.ini index 86768738171..4a2240f1924 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -187,9 +187,6 @@ external_snapshot_name = Publish to snapshot.raintank.io # remove expired snapshot snapshot_remove_expired = true -# remove snapshots after 90 days -snapshot_TTL_days = 90 - #################################### Dashboards ################## [dashboards] @@ -251,7 +248,7 @@ enabled = false allow_sign_up = true client_id = some_id client_secret = some_secret -scopes = user:email +scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token api_url = https://api.github.com/user diff --git a/conf/sample.ini b/conf/sample.ini index 5f13dad4061..3e45ac44d61 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -175,9 +175,6 @@ log_queries = # remove expired snapshot ;snapshot_remove_expired = true -# remove snapshots after 90 days -;snapshot_TTL_days = 90 - #################################### Dashboards History ################## [dashboards] # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1 diff --git a/docker/blocks/prometheus2/docker-compose.yaml b/docker/blocks/prometheus2/docker-compose.yaml new file mode 100644 index 00000000000..7b133ed9000 --- /dev/null +++ b/docker/blocks/prometheus2/docker-compose.yaml @@ -0,0 +1,25 @@ + prometheus: + build: blocks/prometheus2 + network_mode: host + ports: + - "9090:9090" + + node_exporter: + image: prom/node-exporter + network_mode: host + ports: + - "9100:9100" + + fake-prometheus-data: + image: grafana/fake-data-gen + network_mode: host + ports: + - "9091:9091" + environment: + FD_DATASOURCE: prom + + alertmanager: + image: quay.io/prometheus/alertmanager + network_mode: host + ports: + - "9093:9093" diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 716246102bc..0194c69caac 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -61,7 +61,7 @@ Content-Type: application/json "client_id":"some_id", "client_secret":"************", "enabled":"false", - "scopes":"user:email", + "scopes":"user:email,read:org", "team_ids":"", "token_url":"https://github.com/login/oauth/access_token" }, diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 468a812c709..364b55b0cfc 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -90,7 +90,7 @@ Content-Type: application/json ## Get a single data source by Name -`GET /api/datasources/:name` +`GET /api/datasources/name/:name` **Example Request**: diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 498ab7fc8df..06f01298cb8 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -354,7 +354,7 @@ enabled = true allow_sign_up = true client_id = YOUR_GITHUB_APP_CLIENT_ID client_secret = YOUR_GITHUB_APP_CLIENT_SECRET -scopes = user:email +scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token api_url = https://api.github.com/user @@ -387,6 +387,7 @@ scopes = user:email,read:org team_ids = 150,300 auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token +api_url = https://api.github.com/user allow_sign_up = true ``` @@ -405,6 +406,7 @@ client_secret = YOUR_GITHUB_APP_CLIENT_SECRET scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token +api_url = https://api.github.com/user allow_sign_up = true # space-delimited organization names allowed_organizations = github google @@ -795,12 +797,9 @@ Set root url to a Grafana instance where you want to publish external snapshots ### external_snapshot_name Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io` -### remove expired snapshot +### snapshot_remove_expired Enabled to automatically remove expired snapshots -### remove snapshots after 90 days -Time to live for snapshots. - ## [external_image_storage] These options control how images should be made public so they can be shared on services like slack. @@ -878,6 +877,4 @@ Defaults to true. Set to false to disable alerting engine and hide Alerting from ### execute_alerts -### execute_alerts = true - Makes it possible to turn off alert rule execution. diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index a45afe35e6b..bb85a579a0c 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,7 +16,7 @@ weight = 1 Description | Download ------------ | ------------- Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb) -Beta for Debian-based Linux | [grafana_5.0.0-beta4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb) +Beta for Debian-based Linux | [grafana_5.0.0-beta5_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta5_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -33,9 +33,9 @@ sudo dpkg -i grafana_4.6.3_amd64.deb ## Install Latest Beta ```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta5_amd64.deb sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_5.0.0-beta4_amd64.deb +sudo dpkg -i grafana_5.0.0-beta5_amd64.deb ``` ## APT Repository diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index e97ab17a697..1e0645e74c1 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -16,7 +16,7 @@ weight = 2 Description | Download ------------ | ------------- Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm) -Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm) +Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta5 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -32,7 +32,7 @@ $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/g ## Install Beta ```bash -$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.x86_64.rpm ``` Or install manually using `rpm`. diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 07f5abde52f..5e8a204d11d 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -14,7 +14,7 @@ weight = 3 Description | Download ------------ | ------------- Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip) -Latest beta package for Windows | [grafana.5.0.0-beta4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.windows-x64.zip) +Latest beta package for Windows | [grafana.5.0.0-beta5.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -31,9 +31,9 @@ on windows. Edit `custom.ini` and uncomment the `http_port` configuration option (`;` is the comment character in ini files) and change it to something like `8080` or similar. That port should not require extra Windows privileges. -Start Grafana by executing `grafana-server.exe`, preferably from the +Start Grafana by executing `grafana-server.exe`, located in the `bin` directory, preferably from the command line. If you want to run Grafana as windows service, download -[NSSM](https://nssm.cc/). It is very easy add Grafana as a Windows +[NSSM](https://nssm.cc/). It is very easy to add Grafana as a Windows service using that tool. Read more about the [configuration options]({{< relref "configuration.md" >}}). @@ -43,7 +43,3 @@ Read more about the [configuration options]({{< relref "configuration.md" >}}). The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). - -Copy `conf/sample.ini` to a file named `conf/custom.ini` and change the -web server port to something like 8080. The default Grafana port, 3000, -requires special privileges on Windows. diff --git a/package.json b/package.json index 1cae6360f01..df4359ef5c1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.0.0-beta4", + "version": "5.0.1-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -155,7 +155,7 @@ "prop-types": "^15.6.0", "react": "^16.2.0", "react-dom": "^16.2.0", - "react-grid-layout": "^0.16.2", + "react-grid-layout-grafana": "0.16.0", "react-highlight-words": "^0.10.0", "react-popper": "^0.7.5", "react-select": "^1.1.0", @@ -165,7 +165,7 @@ "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "^5.4.3", "tether": "^1.4.0", - "tether-drop": "https://github.com/torkelo/drop", + "tether-drop": "https://github.com/torkelo/drop/tarball/master", "tinycolor2": "^1.4.1" } } diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 5dea3655251..08ba2a89dd9 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=5.0.0-beta4 -rpm_ver=5.0.0-beta4 +deb_ver=5.0.0-beta5 +rpm_ver=5.0.0-beta5 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/api.go b/pkg/api/api.go index 1320663f630..1b7e30e34ad 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -106,7 +106,7 @@ func (hs *HttpServer) registerRoutes() { r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", GetSharingOptions) r.Get("/api/snapshots/:key", GetDashboardSnapshot) - r.Get("/api/snapshots-delete/:key", reqEditorRole, DeleteDashboardSnapshot) + r.Get("/api/snapshots-delete/:key", reqEditorRole, wrap(DeleteDashboardSnapshot)) // api renew session based on remember cookie r.Get("/api/login/ping", quota("session"), LoginApiPing) @@ -246,6 +246,24 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) + // Folders + apiRoute.Group("/folders", func(folderRoute RouteRegister) { + folderRoute.Get("/", wrap(GetFolders)) + folderRoute.Get("/id/:id", wrap(GetFolderById)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) { + folderUidRoute.Get("/", wrap(GetFolderByUid)) + folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) + folderUidRoute.Delete("/", wrap(DeleteFolder)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) { + folderPermissionRoute.Get("/", wrap(GetFolderPermissionList)) + folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions)) + }) + }) + }) + // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) @@ -266,9 +284,9 @@ func (hs *HttpServer) registerRoutes() { dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) - dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { - aclRoute.Get("/", wrap(GetDashboardAclList)) - aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) + dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) { + dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList)) + dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions)) }) }) }) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index a079f6f7681..53174075c25 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -137,6 +137,7 @@ func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dash if err := bus.Dispatch(&query); err != nil { return nil, ApiError(404, "Dashboard not found", err) } + return query.Result, nil } @@ -166,8 +167,10 @@ func DeleteDashboard(c *middleware.Context) Response { return ApiError(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": dash.Title} - return Json(200, resp) + return Json(200, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), + }) } func DeleteDashboardByUid(c *middleware.Context) Response { @@ -186,8 +189,10 @@ func DeleteDashboardByUid(c *middleware.Context) Response { return ApiError(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": dash.Title} - return Json(200, resp) + return Json(200, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), + }) } func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { @@ -406,6 +411,18 @@ func GetDashboardVersion(c *middleware.Context) Response { // POST /api/dashboards/calculate-diff performs diffs on two dashboards func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response { + guardianBase := guardian.New(apiOptions.Base.DashboardId, c.OrgId, c.SignedInUser) + if canSave, err := guardianBase.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + if apiOptions.Base.DashboardId != apiOptions.New.DashboardId { + guardianNew := guardian.New(apiOptions.New.DashboardId, c.OrgId, c.SignedInUser) + if canSave, err := guardianNew.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + } + options := dashdiffs.Options{ OrgId: c.OrgId, DiffType: dashdiffs.ParseDiffType(apiOptions.DiffType), @@ -431,9 +448,9 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff if options.DiffType == dashdiffs.DiffDelta { return Respond(200, result.Delta).Header("Content-Type", "application/json") - } else { - return Respond(200, result.Delta).Header("Content-Type", "text/html") } + + return Respond(200, result.Delta).Header("Content-Type", "text/html") } // RestoreDashboardVersion restores a dashboard to the given version. diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go deleted file mode 100644 index d6b7e305daf..00000000000 --- a/pkg/api/dashboard_acl_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package api - -import ( - "testing" - - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" - - . "github.com/smartystreets/goconvey/convey" -) - -func TestDashboardAclApiEndpoint(t *testing.T) { - Convey("Given a dashboard acl", t, func() { - mockResult := []*m.DashboardAclInfoDTO{ - {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, - {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, - {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, - {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, - {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, - } - dtoRes := transformDashboardAclsToDTOs(mockResult) - - getDashboardQueryResult := m.NewDashboard("Dash") - var getDashboardNotFoundError error - - bus.AddHandler("test", func(query *m.GetDashboardQuery) error { - query.Result = getDashboardQueryResult - return getDashboardNotFoundError - }) - - bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { - query.Result = dtoRes - return nil - }) - - bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { - query.Result = mockResult - return nil - }) - - teamResp := []*m.Team{} - bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = teamResp - return nil - }) - - // This tests four scenarios: - // 1. user is an org admin - // 2. user is an org editor AND has been granted admin permission for the dashboard - // 3. user is an org viewer AND has been granted edit permission for the dashboard - // 4. user is an org editor AND has no permissions for the dashboard - - Convey("When user is org admin", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { - Convey("Should be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 200) - - respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) - So(err, ShouldBeNil) - So(len(respJSON.MustArray()), ShouldEqual, 5) - So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) - So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) - }) - }) - - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { - getDashboardNotFoundError = m.ErrDashboardNotFound - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - Convey("Should not be able to access ACL", func() { - So(sc.resp.Code, ShouldEqual, 404) - }) - }) - - Convey("Should not be able to update permissions for non-existing dashboard", func() { - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserId: 1000, Permission: m.PERMISSION_ADMIN}, - }, - } - - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) { - getDashboardNotFoundError = m.ErrDashboardNotFound - CallPostAcl(sc) - So(sc.resp.Code, ShouldEqual, 404) - }) - }) - }) - - Convey("When user is org editor and has admin permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - Convey("Should be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - - Convey("Should not be able to downgrade their own Admin permission", func() { - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserId: TestUserID, Permission: m.PERMISSION_EDIT}, - }, - } - - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - CallPostAcl(sc) - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - - Convey("Should be able to update permissions", func() { - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserId: TestUserID, Permission: m.PERMISSION_ADMIN}, - {UserId: 2, Permission: m.PERMISSION_EDIT}, - }, - } - - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) - - CallPostAcl(sc) - So(sc.resp.Code, ShouldEqual, 200) - }) - }) - - }) - - Convey("When user is org viewer and has edit permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) - - // Getting the permissions is an Admin permission - Convey("Should not be able to get list of permissions from ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - }) - - Convey("When user is org editor and not in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - - Convey("Should not be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - - So(sc.resp.Code, ShouldEqual, 403) - }) - }) - }) - }) -} - -func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardAclInfoDTO { - dtos := make([]*m.DashboardAclInfoDTO, 0) - - for _, acl := range acls { - dto := &m.DashboardAclInfoDTO{ - OrgId: acl.OrgId, - DashboardId: acl.DashboardId, - Permission: acl.Permission, - UserId: acl.UserId, - TeamId: acl.TeamId, - } - dtos = append(dtos, dto) - } - - return dtos -} - -func CallPostAcl(sc *scenarioContext) { - bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { - return nil - }) - - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() -} - -func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { - Convey(desc+" "+url, func() { - defer bus.ClearBusHandlers() - - sc := setupScenarioContext(url) - - sc.defaultHandler = wrap(func(c *middleware.Context) Response { - sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role - - return UpdateDashboardAcl(c, cmd) - }) - - sc.m.Post(routePattern, sc.defaultHandler) - - fn(sc) - }) -} diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_permission.go similarity index 66% rename from pkg/api/dashboard_acl.go rename to pkg/api/dashboard_permission.go index 13b29db78e6..419825644c8 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_permission.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" ) -func GetDashboardAclList(c *middleware.Context) Response { +func GetDashboardPermissionList(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") @@ -18,15 +18,15 @@ func GetDashboardAclList(c *middleware.Context) Response { return rsp } - guardian := guardian.New(dashId, c.OrgId, c.SignedInUser) + g := guardian.New(dashId, c.OrgId, c.SignedInUser) - if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { + if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { return dashboardGuardianResponse(err) } - acl, err := guardian.GetAcl() + acl, err := g.GetAcl() if err != nil { - return ApiError(500, "Failed to get dashboard acl", err) + return ApiError(500, "Failed to get dashboard permissions", err) } for _, perm := range acl { @@ -38,7 +38,7 @@ func GetDashboardAclList(c *middleware.Context) Response { return Json(200, acl) } -func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { +func UpdateDashboardPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { dashId := c.ParamsInt64(":dashboardId") _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") @@ -46,8 +46,8 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom return rsp } - guardian := guardian.New(dashId, c.OrgId, c.SignedInUser) - if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { + g := guardian.New(dashId, c.OrgId, c.SignedInUser) + if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { return dashboardGuardianResponse(err) } @@ -67,8 +67,13 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom }) } - if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { if err != nil { + if err == guardian.ErrGuardianPermissionExists || + err == guardian.ErrGuardianOverride { + return ApiError(400, err.Error(), err) + } + return ApiError(500, "Error while checking dashboard permissions", err) } @@ -82,5 +87,5 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom return ApiError(500, "Failed to create permission", err) } - return ApiSuccess("Dashboard acl updated") + return ApiSuccess("Dashboard permissions updated") } diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go new file mode 100644 index 00000000000..03231338268 --- /dev/null +++ b/pkg/api/dashboard_permission_test.go @@ -0,0 +1,210 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardPermissionApiEndpoint(t *testing.T) { + Convey("Dashboard permissions test", t, func() { + Convey("Given dashboard not exists", func() { + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + return m.ErrDashboardNotFound + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("Given user has no admin permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has admin permissions and permissions to update", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: true, + GetAclValue: []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, + }, + }) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { + callGetDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(len(respJSON.MustArray()), ShouldEqual, 5) + So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) + So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("When trying to update permissions with duplicate permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists, + }) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("When trying to override inherited permissions with lower presedence", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride}, + ) + + getDashboardQueryResult := m.NewDashboard("Dash") + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = getDashboardQueryResult + return nil + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) { + callUpdateDashboardPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + }) +} + +func callGetDashboardPermissions(sc *scenarioContext) { + sc.handlerFunc = GetDashboardPermissionList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callUpdateDashboardPermissions(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func updateDashboardPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.OrgId = TestOrgID + sc.context.UserId = TestUserID + + return UpdateDashboardPermissions(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index a834bd4717d..c10302faf32 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -56,6 +57,7 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho }) } +// GET /api/snapshots/:key func GetDashboardSnapshot(c *middleware.Context) { key := c.Params(":key") query := &m.GetDashboardSnapshotQuery{Key: key} @@ -90,18 +92,43 @@ func GetDashboardSnapshot(c *middleware.Context) { c.JSON(200, dto) } -func DeleteDashboardSnapshot(c *middleware.Context) { +// GET /api/snapshots-delete/:key +func DeleteDashboardSnapshot(c *middleware.Context) Response { key := c.Params(":key") + + query := &m.GetDashboardSnapshotQuery{DeleteKey: key} + + err := bus.Dispatch(query) + if err != nil { + return ApiError(500, "Failed to get dashboard snapshot", err) + } + + if query.Result == nil { + return ApiError(404, "Failed to get dashboard snapshot", nil) + } + dashboard := query.Result.Dashboard + dashboardId := dashboard.Get("id").MustInt64() + + guardian := guardian.New(dashboardId, c.OrgId, c.SignedInUser) + canEdit, err := guardian.CanEdit() + if err != nil { + return ApiError(500, "Error while checking permissions for snapshot", err) + } + + if !canEdit && query.Result.UserId != c.SignedInUser.UserId { + return ApiError(403, "Access denied to this snapshot", nil) + } + cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: key} if err := bus.Dispatch(cmd); err != nil { - c.JsonApiErr(500, "Failed to delete dashboard snapshot", err) - return + return ApiError(500, "Failed to delete dashboard snapshot", err) } - c.JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."}) + return Json(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."}) } +// GET /api/dashboard/snapshots func SearchDashboardSnapshots(c *middleware.Context) Response { query := c.Query("query") limit := c.QueryInt("limit") @@ -111,9 +138,10 @@ func SearchDashboardSnapshots(c *middleware.Context) Response { } searchQuery := m.GetDashboardSnapshotsQuery{ - Name: query, - Limit: limit, - OrgId: c.OrgId, + Name: query, + Limit: limit, + OrgId: c.OrgId, + SignedInUser: c.SignedInUser, } err := bus.Dispatch(&searchQuery) diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go new file mode 100644 index 00000000000..87c2b9e99d4 --- /dev/null +++ b/pkg/api/dashboard_snapshot_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDashboardSnapshotApiEndpoint(t *testing.T) { + Convey("Given a single snapshot", t, func() { + jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`)) + + mockSnapshotResult := &m.DashboardSnapshot{ + Id: 1, + Dashboard: jsonModel, + Expires: time.Now().Add(time.Duration(1000) * time.Second), + UserId: 999999, + } + + bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error { + query.Result = mockSnapshotResult + return nil + }) + + bus.AddHandler("test", func(cmd *m.DeleteDashboardSnapshotCommand) error { + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + aclMockResp := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + teamResp := []*m.Team{} + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = teamResp + return nil + }) + + Convey("When user has editor role and is not in the ACL", func() { + Convey("Should not be able to delete snapshot", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is editor and dashboard has default ACL", func() { + aclMockResp = []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + Convey("Should be able to delete a snapshot", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + }) + }) + }) + + Convey("When user is editor and is the creator of the snapshot", func() { + aclMockResp = []*m.DashboardAclInfoDTO{} + mockSnapshotResult.UserId = TestUserID + + Convey("Should be able to delete a snapshot", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + }) + }) + }) + }) +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 046075fc622..3857fa0b9e1 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -743,11 +743,57 @@ func TestDashboardApiEndpoint(t *testing.T) { } }) }) + + Convey("Given two dashboards being compared", t, func() { + mockResult := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = mockResult + return nil + }) + + bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { + query.Result = &m.DashboardVersion{ + Data: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dash" + string(query.DashboardId), + }), + } + return nil + }) + + cmd := dtos.CalculateDiffOptions{ + Base: dtos.CalculateDiffTarget{ + DashboardId: 1, + Version: 1, + }, + New: dtos.CalculateDiffTarget{ + DashboardId: 2, + Version: 2, + }, + DiffType: "basic", + } + + Convey("when user does not have permission", func() { + role := m.ROLE_VIEWER + + postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("when user does have permission", func() { + role := m.ROLE_ADMIN + + postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { - sc.handlerFunc = GetDashboard - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + CallGetDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) @@ -758,6 +804,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta return dash } +func CallGetDashboard(sc *scenarioContext) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + func CallGetDashboardVersion(sc *scenarioContext) { bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { query.Result = &m.DashboardVersion{} @@ -831,6 +882,28 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d }) } +func postDiffScenario(desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, role m.RoleType, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{ + OrgId: TestOrgID, + UserId: TestUserID, + } + sc.context.OrgRole = role + + return CalculateDashboardDiff(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + func (sc *scenarioContext) ToJson() *simplejson.Json { var result *simplejson.Json err := json.NewDecoder(sc.resp.Body).Decode(&result) diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go new file mode 100644 index 00000000000..469656c6f8f --- /dev/null +++ b/pkg/api/dtos/folder.go @@ -0,0 +1,25 @@ +package dtos + +import "time" + +type Folder struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` + Url string `json:"url"` + HasAcl bool `json:"hasAcl"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CreatedBy string `json:"createdBy"` + Created time.Time `json:"created"` + UpdatedBy string `json:"updatedBy"` + Updated time.Time `json:"updated"` + Version int `json:"version"` +} + +type FolderSearchHit struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` +} diff --git a/pkg/api/folder.go b/pkg/api/folder.go new file mode 100644 index 00000000000..e3c4f127569 --- /dev/null +++ b/pkg/api/folder.go @@ -0,0 +1,147 @@ +package api + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/util" +) + +func GetFolders(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folders, err := s.GetFolders(c.QueryInt("limit")) + + if err != nil { + return toFolderError(err) + } + + result := make([]dtos.FolderSearchHit, 0) + + for _, f := range folders { + result = append(result, dtos.FolderSearchHit{ + Id: f.Id, + Uid: f.Uid, + Title: f.Title, + }) + } + + return Json(200, result) +} + +func GetFolderByUid(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUid(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, folder)) +} + +func GetFolderById(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderById(c.ParamsInt64(":id")) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, folder)) +} + +func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + err := s.CreateFolder(&cmd) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, cmd.Result)) +} + +func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + err := s.UpdateFolder(c.Params(":uid"), &cmd) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, cmd.Result)) +} + +func DeleteFolder(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + f, err := s.DeleteFolder(c.Params(":uid")) + if err != nil { + return toFolderError(err) + } + + return Json(200, util.DynMap{ + "title": f.Title, + "message": fmt.Sprintf("Folder %s deleted", f.Title), + }) +} + +func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder { + canEdit, _ := g.CanEdit() + canSave, _ := g.CanSave() + canAdmin, _ := g.CanAdmin() + + // Finding creator and last updater of the folder + updater, creator := "Anonymous", "Anonymous" + if folder.CreatedBy > 0 { + creator = getUserLogin(folder.CreatedBy) + } + if folder.UpdatedBy > 0 { + updater = getUserLogin(folder.UpdatedBy) + } + + return dtos.Folder{ + Id: folder.Id, + Uid: folder.Uid, + Title: folder.Title, + Url: folder.Url, + HasAcl: folder.HasAcl, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + Version: folder.Version, + } +} + +func toFolderError(err error) Response { + if err == m.ErrFolderTitleEmpty || + err == m.ErrFolderSameNameExists || + err == m.ErrFolderWithSameUIDExists || + err == m.ErrDashboardTypeMismatch || + err == m.ErrDashboardInvalidUid || + err == m.ErrDashboardUidToLong { + return ApiError(400, err.Error(), nil) + } + + if err == m.ErrFolderAccessDenied { + return ApiError(403, "Access denied", err) + } + + if err == m.ErrFolderNotFound { + return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()}) + } + + if err == m.ErrFolderVersionMismatch { + return Json(412, util.DynMap{"status": "version-mismatch", "message": m.ErrFolderVersionMismatch.Error()}) + } + + return ApiError(500, "Folder API error", err) +} diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go new file mode 100644 index 00000000000..7c8aba87337 --- /dev/null +++ b/pkg/api/folder_permission.go @@ -0,0 +1,108 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" +) + +func GetFolderPermissionList(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUid(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + + if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { + return toFolderError(m.ErrFolderAccessDenied) + } + + acl, err := g.GetAcl() + if err != nil { + return ApiError(500, "Failed to get folder permissions", err) + } + + for _, perm := range acl { + perm.FolderId = folder.Id + perm.DashboardId = 0 + + if perm.Slug != "" { + perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) + } + } + + return Json(200, acl) +} + +func UpdateFolderPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUid(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + canAdmin, err := g.CanAdmin() + if err != nil { + return toFolderError(err) + } + + if !canAdmin { + return toFolderError(m.ErrFolderAccessDenied) + } + + cmd := m.UpdateDashboardAclCommand{} + cmd.DashboardId = folder.Id + + for _, item := range apiCmd.Items { + cmd.Items = append(cmd.Items, &m.DashboardAcl{ + OrgId: c.OrgId, + DashboardId: folder.Id, + UserId: item.UserId, + TeamId: item.TeamId, + Role: item.Role, + Permission: item.Permission, + Created: time.Now(), + Updated: time.Now(), + }) + } + + if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if err != nil { + if err == guardian.ErrGuardianPermissionExists || + err == guardian.ErrGuardianOverride { + return ApiError(400, err.Error(), err) + } + + return ApiError(500, "Error while checking folder permissions", err) + } + + return ApiError(403, "Cannot remove own admin permission for a folder", nil) + } + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrDashboardAclInfoMissing { + err = m.ErrFolderAclInfoMissing + } + if err == m.ErrDashboardPermissionDashboardEmpty { + err = m.ErrFolderPermissionFolderEmpty + } + + if err == m.ErrFolderAclInfoMissing || err == m.ErrFolderPermissionFolderEmpty { + return ApiError(409, err.Error(), err) + } + + return ApiError(500, "Failed to create permission", err) + } + + return ApiSuccess("Folder permissions updated") +} diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go new file mode 100644 index 00000000000..552577963b4 --- /dev/null +++ b/pkg/api/folder_permission_test.go @@ -0,0 +1,242 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFolderPermissionApiEndpoint(t *testing.T) { + Convey("Folder permissions test", t, func() { + Convey("Given folder not exists", func() { + mock := &fakeFolderService{ + GetFolderByUidError: m.ErrFolderNotFound, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + Reset(func() { + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("Given user has no admin permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) + + mock := &fakeFolderService{ + GetFolderByUidResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("Given user has admin permissions and permissions to update", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: true, + GetAclValue: []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN}, + }, + }) + + mock := &fakeFolderService{ + GetFolderByUidResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(len(respJSON.MustArray()), ShouldEqual, 5) + So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2) + So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("When trying to update permissions with duplicate permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists, + }) + + mock := &fakeFolderService{ + GetFolderByUidResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("When trying to override inherited permissions with lower presedence", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanAdminValue: true, + CheckPermissionBeforeUpdateValue: false, + CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride}, + ) + + mock := &fakeFolderService{ + GetFolderByUidResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 400) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + }) +} + +func callGetFolderPermissions(sc *scenarioContext) { + sc.handlerFunc = GetFolderPermissionList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callUpdateFolderPermissions(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func updateFolderPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.OrgId = TestOrgID + sc.context.UserId = TestUserID + + return UpdateFolderPermissions(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go new file mode 100644 index 00000000000..0ab3cc7d7c9 --- /dev/null +++ b/pkg/api/folder_test.go @@ -0,0 +1,254 @@ +package api + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFoldersApiEndpoint(t *testing.T) { + Convey("Create/update folder response tests", t, func() { + Convey("Given a correct request for creating a folder", func() { + cmd := m.CreateFolderCommand{ + Uid: "uid", + Title: "Folder", + } + + mock := &fakeFolderService{ + CreateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder"}, + } + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + + Convey("It should return correct response data", func() { + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + So(folder.Id, ShouldEqual, 1) + So(folder.Uid, ShouldEqual, "uid") + So(folder.Title, ShouldEqual, "Folder") + }) + }) + }) + + Convey("Given incorrect requests for creating a folder", func() { + testCases := []struct { + Error error + ExpectedStatusCode int + }{ + {Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400}, + {Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400}, + {Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403}, + {Error: m.ErrFolderNotFound, ExpectedStatusCode: 404}, + {Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412}, + {Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + } + + cmd := m.CreateFolderCommand{ + Uid: "uid", + Title: "Folder", + } + + for _, tc := range testCases { + mock := &fakeFolderService{ + CreateFolderError: tc.Error, + } + + createFolderScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()), "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + if sc.resp.Code != tc.ExpectedStatusCode { + t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code) + } + }) + } + }) + + Convey("Given a correct request for updating a folder", func() { + cmd := m.UpdateFolderCommand{ + Title: "Folder upd", + } + + mock := &fakeFolderService{ + UpdateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder upd"}, + } + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + + Convey("It should return correct response data", func() { + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + So(folder.Id, ShouldEqual, 1) + So(folder.Uid, ShouldEqual, "uid") + So(folder.Title, ShouldEqual, "Folder upd") + }) + }) + }) + + Convey("Given incorrect requests for updating a folder", func() { + testCases := []struct { + Error error + ExpectedStatusCode int + }{ + {Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400}, + {Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400}, + {Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403}, + {Error: m.ErrFolderNotFound, ExpectedStatusCode: 404}, + {Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412}, + {Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + } + + cmd := m.UpdateFolderCommand{ + Title: "Folder upd", + } + + for _, tc := range testCases { + mock := &fakeFolderService{ + UpdateFolderError: tc.Error, + } + + updateFolderScenario(fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()), "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + if sc.resp.Code != tc.ExpectedStatusCode { + t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code) + } + }) + } + }) + }) +} + +func callGetFolderByUid(sc *scenarioContext) { + sc.handlerFunc = GetFolderByUid + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callDeleteFolder(sc *scenarioContext) { + sc.handlerFunc = DeleteFolder + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() +} + +func callCreateFolder(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func createFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.CreateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} + + return CreateFolder(c, cmd) + }) + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + sc.m.Post(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewFolderService = origNewFolderService + }() + + fn(sc) + }) +} + +func callUpdateFolder(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() +} + +func updateFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.UpdateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} + + return UpdateFolder(c, cmd) + }) + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + sc.m.Put(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewFolderService = origNewFolderService + }() + + fn(sc) + }) +} + +type fakeFolderService struct { + GetFoldersResult []*models.Folder + GetFoldersError error + GetFolderByUidResult *models.Folder + GetFolderByUidError error + GetFolderByIdResult *models.Folder + GetFolderByIdError error + CreateFolderResult *models.Folder + CreateFolderError error + UpdateFolderResult *models.Folder + UpdateFolderError error + DeleteFolderResult *models.Folder + DeleteFolderError error + DeletedFolderUids []string +} + +func (s *fakeFolderService) GetFolders(limit int) ([]*models.Folder, error) { + return s.GetFoldersResult, s.GetFoldersError +} + +func (s *fakeFolderService) GetFolderById(id int64) (*models.Folder, error) { + return s.GetFolderByIdResult, s.GetFolderByIdError +} + +func (s *fakeFolderService) GetFolderByUid(uid string) (*models.Folder, error) { + return s.GetFolderByUidResult, s.GetFolderByUidError +} + +func (s *fakeFolderService) CreateFolder(cmd *models.CreateFolderCommand) error { + cmd.Result = s.CreateFolderResult + return s.CreateFolderError +} + +func (s *fakeFolderService) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error { + cmd.Result = s.UpdateFolderResult + return s.UpdateFolderError +} + +func (s *fakeFolderService) DeleteFolder(uid string) (*models.Folder, error) { + s.DeletedFolderUids = append(s.DeletedFolderUids, uid) + return s.DeleteFolderResult, s.DeleteFolderError +} + +func mockFolderService(mock *fakeFolderService) { + dashboards.NewFolderService = func(orgId int64, user *models.SignedInUser) dashboards.FolderService { + return mock + } +} diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index 202b519207d..5b91b2a70b4 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -26,6 +26,8 @@ func (p PermissionType) String() string { var ( ErrDashboardAclInfoMissing = errors.New("User id and team id cannot both be empty for a dashboard permission.") ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.") + ErrFolderAclInfoMissing = errors.New("User id and team id cannot both be empty for a folder permission.") + ErrFolderPermissionFolderEmpty = errors.New("Folder Id must be greater than zero for a folder permission.") ) // Dashboard ACL model @@ -45,7 +47,8 @@ type DashboardAcl struct { type DashboardAclInfoDTO struct { OrgId int64 `json:"-"` - DashboardId int64 `json:"dashboardId"` + DashboardId int64 `json:"dashboardId,omitempty"` + FolderId int64 `json:"folderId,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` @@ -65,6 +68,27 @@ type DashboardAclInfoDTO struct { Url string `json:"url"` } +func (dto *DashboardAclInfoDTO) hasSameRoleAs(other *DashboardAclInfoDTO) bool { + if dto.Role == nil || other.Role == nil { + return false + } + + return dto.UserId <= 0 && dto.TeamId <= 0 && dto.UserId == other.UserId && dto.TeamId == other.TeamId && *dto.Role == *other.Role +} + +func (dto *DashboardAclInfoDTO) hasSameUserAs(other *DashboardAclInfoDTO) bool { + return dto.UserId > 0 && dto.UserId == other.UserId +} + +func (dto *DashboardAclInfoDTO) hasSameTeamAs(other *DashboardAclInfoDTO) bool { + return dto.TeamId > 0 && dto.TeamId == other.TeamId +} + +// IsDuplicateOf returns true if other item has same role, same user or same team +func (dto *DashboardAclInfoDTO) IsDuplicateOf(other *DashboardAclInfoDTO) bool { + return dto.hasSameRoleAs(other) || dto.hasSameUserAs(other) || dto.hasSameTeamAs(other) +} + // // COMMANDS // diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index 9273b88f291..ec8b19f3c18 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -64,10 +64,12 @@ type DeleteDashboardSnapshotCommand struct { } type DeleteExpiredSnapshotsCommand struct { + DeletedRows int64 } type GetDashboardSnapshotQuery struct { - Key string + Key string + DeleteKey string Result *DashboardSnapshot } @@ -76,9 +78,10 @@ type DashboardSnapshots []*DashboardSnapshot type DashboardSnapshotsList []*DashboardSnapshotDTO type GetDashboardSnapshotsQuery struct { - Name string - Limit int - OrgId int64 + Name string + Limit int + OrgId int64 + SignedInUser *SignedInUser Result DashboardSnapshotsList } diff --git a/pkg/models/dashboard_version.go b/pkg/models/dashboard_version.go index 4acb4282a58..9f5f18cb263 100644 --- a/pkg/models/dashboard_version.go +++ b/pkg/models/dashboard_version.go @@ -75,4 +75,5 @@ type GetDashboardVersionsQuery struct { // type DeleteExpiredVersionsCommand struct { + DeletedRows int64 } diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 5bf37136548..4b771038df6 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -14,7 +14,7 @@ import ( // Typed errors var ( ErrDashboardNotFound = errors.New("Dashboard not found") - ErrFolderNotFound = errors.New("Folder not found") + ErrDashboardFolderNotFound = errors.New("Folder not found") ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") @@ -112,9 +112,9 @@ func NewDashboard(title string) *Dashboard { // NewDashboardFolder creates a new dashboard folder func NewDashboardFolder(title string) *Dashboard { folder := NewDashboard(title) + folder.IsFolder = true folder.Data.Set("schemaVersion", 16) - folder.Data.Set("editable", true) - folder.Data.Set("hideControls", true) + folder.Data.Set("version", 0) folder.IsFolder = true return folder } @@ -166,10 +166,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { userId = -1 } - if dash.Data.Get("version").MustInt(0) == 0 { - dash.CreatedBy = userId - } - dash.UpdatedBy = userId dash.OrgId = cmd.OrgId dash.PluginId = cmd.PluginId diff --git a/pkg/models/folders.go b/pkg/models/folders.go new file mode 100644 index 00000000000..c61620a11fc --- /dev/null +++ b/pkg/models/folders.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "strings" + "time" +) + +// Typed errors +var ( + ErrFolderNotFound = errors.New("Folder not found") + ErrFolderVersionMismatch = errors.New("The folder has been changed by someone else") + ErrFolderTitleEmpty = errors.New("Folder title cannot be empty") + ErrFolderWithSameUIDExists = errors.New("A folder/dashboard with the same uid already exists") + ErrFolderSameNameExists = errors.New("A folder or dashboard in the general folder with the same name already exists") + ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id") + ErrFolderAccessDenied = errors.New("Access denied to folder") +) + +type Folder struct { + Id int64 + Uid string + Title string + Url string + Version int + + Created time.Time + Updated time.Time + + UpdatedBy int64 + CreatedBy int64 + HasAcl bool +} + +// GetDashboardModel turns the command into the savable model +func (cmd *CreateFolderCommand) GetDashboardModel(orgId int64, userId int64) *Dashboard { + dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title)) + dashFolder.OrgId = orgId + dashFolder.SetUid(strings.TrimSpace(cmd.Uid)) + + if userId == 0 { + userId = -1 + } + + dashFolder.CreatedBy = userId + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() + + return dashFolder +} + +// UpdateDashboardModel updates an existing model from command into model for update +func (cmd *UpdateFolderCommand) UpdateDashboardModel(dashFolder *Dashboard, orgId int64, userId int64) { + dashFolder.OrgId = orgId + dashFolder.Title = strings.TrimSpace(cmd.Title) + dashFolder.Data.Set("title", dashFolder.Title) + + if cmd.Uid != "" { + dashFolder.SetUid(cmd.Uid) + } + + dashFolder.SetVersion(cmd.Version) + dashFolder.IsFolder = true + + if userId == 0 { + userId = -1 + } + + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() +} + +// +// COMMANDS +// + +type CreateFolderCommand struct { + Uid string `json:"uid"` + Title string `json:"title"` + + Result *Folder +} + +type UpdateFolderCommand struct { + Uid string `json:"uid"` + Title string `json:"title"` + Version int `json:"version"` + Overwrite bool `json:"overwrite"` + + Result *Folder +} diff --git a/pkg/services/alerting/notifiers/teams.go b/pkg/services/alerting/notifiers/teams.go index 605b2742325..851e5a01c75 100644 --- a/pkg/services/alerting/notifiers/teams.go +++ b/pkg/services/alerting/notifiers/teams.go @@ -82,6 +82,8 @@ func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { message := this.Mention if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok. message += " " + evalContext.Rule.Message + } else { + message += " " // summary must not be empty } body := map[string]interface{}{ diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index f9dcfce51b7..5e9efeea3b0 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -83,11 +83,21 @@ func (service *CleanUpService) cleanUpTmpFiles() { } func (service *CleanUpService) deleteExpiredSnapshots() { - bus.Dispatch(&m.DeleteExpiredSnapshotsCommand{}) + cmd := m.DeleteExpiredSnapshotsCommand{} + if err := bus.Dispatch(&cmd); err != nil { + service.log.Error("Failed to delete expired snapshots", "error", err.Error()) + } else { + service.log.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows) + } } func (service *CleanUpService) deleteExpiredDashboardVersions() { - bus.Dispatch(&m.DeleteExpiredVersionsCommand{}) + cmd := m.DeleteExpiredVersionsCommand{} + if err := bus.Dispatch(&cmd); err != nil { + service.log.Error("Failed to delete expired dashboard versions", "error", err.Error()) + } else { + service.log.Debug("Deleted old/expired dashboard versions", "rows affected", cmd.DeletedRows) + } } func (service *CleanUpService) deleteOldLoginAttempts() { diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index baf8131f8fd..1f39394d757 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -41,7 +41,10 @@ type SaveDashboardDTO struct { Dashboard *models.Dashboard } -type dashboardServiceImpl struct{} +type dashboardServiceImpl struct { + orgId int64 + user *models.SignedInUser +} func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { cmd := &models.GetProvisionedDashboardDataQuery{Name: name} @@ -53,7 +56,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod return cmd.Result, nil } -func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) { +func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) { dash := dto.Dashboard dash.Title = strings.TrimSpace(dash.Title) @@ -78,13 +81,15 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) return nil, models.ErrDashboardUidToLong } - validateAlertsCmd := models.ValidateDashboardAlertsCommand{ - OrgId: dto.OrgId, - Dashboard: dash, - } + if validateAlerts { + validateAlertsCmd := models.ValidateDashboardAlertsCommand{ + OrgId: dto.OrgId, + Dashboard: dash, + } - if err := bus.Dispatch(&validateAlertsCmd); err != nil { - return nil, models.ErrDashboardContainsInvalidAlertData + if err := bus.Dispatch(&validateAlertsCmd); err != nil { + return nil, models.ErrDashboardContainsInvalidAlertData + } } validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{ @@ -142,7 +147,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(dto) + cmd, err := dr.buildSaveDashboardCommand(dto, true) if err != nil { return nil, err } @@ -172,7 +177,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(dto) + cmd, err := dr.buildSaveDashboardCommand(dto, false) if err != nil { return nil, err } @@ -191,7 +196,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash } func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { - cmd, err := dr.buildSaveDashboardCommand(dto) + cmd, err := dr.buildSaveDashboardCommand(dto, true) if err != nil { return nil, err } diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go index 4a7dba762f6..965b10655b3 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -17,7 +17,7 @@ func TestDashboardService(t *testing.T) { service := dashboardServiceImpl{} origNewDashboardGuardian := guardian.New - mockDashboardGuardian(&fakeDashboardGuardian{canSave: true}) + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) Convey("Save dashboard validation", func() { dto := &SaveDashboardDTO{} @@ -72,7 +72,7 @@ func TestDashboardService(t *testing.T) { dto.Dashboard.SetUid(tc.Uid) dto.User = &models.SignedInUser{} - _, err := service.buildSaveDashboardCommand(dto) + _, err := service.buildSaveDashboardCommand(dto, true) So(err, ShouldEqual, tc.Error) } }) @@ -93,52 +93,3 @@ func TestDashboardService(t *testing.T) { }) }) } - -func mockDashboardGuardian(mock *fakeDashboardGuardian) { - guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian { - mock.orgId = orgId - mock.dashId = dashId - mock.user = user - return mock - } -} - -type fakeDashboardGuardian struct { - dashId int64 - orgId int64 - user *models.SignedInUser - canSave bool - canEdit bool - canView bool - canAdmin bool - hasPermission bool - checkPermissionBeforeUpdate bool -} - -func (g *fakeDashboardGuardian) CanSave() (bool, error) { - return g.canSave, nil -} - -func (g *fakeDashboardGuardian) CanEdit() (bool, error) { - return g.canEdit, nil -} - -func (g *fakeDashboardGuardian) CanView() (bool, error) { - return g.canView, nil -} - -func (g *fakeDashboardGuardian) CanAdmin() (bool, error) { - return g.canAdmin, nil -} - -func (g *fakeDashboardGuardian) HasPermission(permission models.PermissionType) (bool, error) { - return g.hasPermission, nil -} - -func (g *fakeDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) { - return g.checkPermissionBeforeUpdate, nil -} - -func (g *fakeDashboardGuardian) GetAcl() ([]*models.DashboardAclInfoDTO, error) { - return nil, nil -} diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go new file mode 100644 index 00000000000..66afa6306fb --- /dev/null +++ b/pkg/services/dashboards/folder_service.go @@ -0,0 +1,245 @@ +package dashboards + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/search" +) + +// FolderService service for operating on folders +type FolderService interface { + GetFolders(limit int) ([]*models.Folder, error) + GetFolderById(id int64) (*models.Folder, error) + GetFolderByUid(uid string) (*models.Folder, error) + CreateFolder(cmd *models.CreateFolderCommand) error + UpdateFolder(uid string, cmd *models.UpdateFolderCommand) error + DeleteFolder(uid string) (*models.Folder, error) +} + +// NewFolderService factory for creating a new folder service +var NewFolderService = func(orgId int64, user *models.SignedInUser) FolderService { + return &dashboardServiceImpl{ + orgId: orgId, + user: user, + } +} + +func (dr *dashboardServiceImpl) GetFolders(limit int) ([]*models.Folder, error) { + if limit == 0 { + limit = 1000 + } + + searchQuery := search.Query{ + SignedInUser: dr.user, + DashboardIds: make([]int64, 0), + FolderIds: make([]int64, 0), + Limit: limit, + OrgId: dr.orgId, + Type: "dash-folder", + Permission: models.PERMISSION_VIEW, + } + + if err := bus.Dispatch(&searchQuery); err != nil { + return nil, err + } + + folders := make([]*models.Folder, 0) + + for _, hit := range searchQuery.Result { + folders = append(folders, &models.Folder{ + Id: hit.Id, + Uid: hit.Uid, + Title: hit.Title, + }) + } + + return folders, nil +} + +func (dr *dashboardServiceImpl) GetFolderById(id int64) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id} + dashFolder, err := getFolder(query) + + if err != nil { + return nil, toFolderError(err) + } + + g := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + return dashToFolder(dashFolder), nil +} + +func (dr *dashboardServiceImpl) GetFolderByUid(uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} + dashFolder, err := getFolder(query) + + if err != nil { + return nil, toFolderError(err) + } + + g := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + return dashToFolder(dashFolder), nil +} + +func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) error { + dashFolder := cmd.GetDashboardModel(dr.orgId, dr.user.UserId) + + dto := &SaveDashboardDTO{ + Dashboard: dashFolder, + OrgId: dr.orgId, + User: dr.user, + } + + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return toFolderError(err) + } + + err = bus.Dispatch(saveDashboardCmd) + if err != nil { + return toFolderError(err) + } + + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} + dashFolder, err = getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.Result = dashToFolder(dashFolder) + + return nil +} + +func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid} + dashFolder, err := getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId) + + dto := &SaveDashboardDTO{ + Dashboard: dashFolder, + OrgId: dr.orgId, + User: dr.user, + Overwrite: cmd.Overwrite, + } + + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return toFolderError(err) + } + + err = bus.Dispatch(saveDashboardCmd) + if err != nil { + return toFolderError(err) + } + + query = models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} + dashFolder, err = getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.Result = dashToFolder(dashFolder) + + return nil +} + +func (dr *dashboardServiceImpl) DeleteFolder(uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} + dashFolder, err := getFolder(query) + if err != nil { + return nil, toFolderError(err) + } + + guardian := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id} + if err := bus.Dispatch(&deleteCmd); err != nil { + return nil, toFolderError(err) + } + + return dashToFolder(dashFolder), nil +} + +func getFolder(query models.GetDashboardQuery) (*models.Dashboard, error) { + if err := bus.Dispatch(&query); err != nil { + return nil, toFolderError(err) + } + + if !query.Result.IsFolder { + return nil, models.ErrFolderNotFound + } + + return query.Result, nil +} + +func dashToFolder(dash *models.Dashboard) *models.Folder { + return &models.Folder{ + Id: dash.Id, + Uid: dash.Uid, + Title: dash.Title, + HasAcl: dash.HasAcl, + Url: dash.GetUrl(), + Version: dash.Version, + Created: dash.Created, + CreatedBy: dash.CreatedBy, + Updated: dash.Updated, + UpdatedBy: dash.UpdatedBy, + } +} + +func toFolderError(err error) error { + if err == models.ErrDashboardTitleEmpty { + return models.ErrFolderTitleEmpty + } + + if err == models.ErrDashboardUpdateAccessDenied { + return models.ErrFolderAccessDenied + } + + if err == models.ErrDashboardWithSameNameInFolderExists { + return models.ErrFolderSameNameExists + } + + if err == models.ErrDashboardWithSameUIDExists { + return models.ErrFolderWithSameUIDExists + } + + if err == models.ErrDashboardVersionMismatch { + return models.ErrFolderVersionMismatch + } + + if err == models.ErrDashboardNotFound { + return models.ErrFolderNotFound + } + + if err == models.ErrDashboardFailedGenerateUniqueUid { + err = models.ErrFolderFailedGenerateUniqueUid + } + + return err +} diff --git a/pkg/services/dashboards/folder_service_test.go b/pkg/services/dashboards/folder_service_test.go new file mode 100644 index 00000000000..6357e84805a --- /dev/null +++ b/pkg/services/dashboards/folder_service_test.go @@ -0,0 +1,191 @@ +package dashboards + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFolderService(t *testing.T) { + Convey("Folder service tests", t, func() { + service := dashboardServiceImpl{ + orgId: 1, + user: &models.SignedInUser{UserId: 1}, + } + + Convey("Given user has no permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = models.NewDashboardFolder("Folder") + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return models.ErrDashboardUpdateAccessDenied + }) + + Convey("When get folder by id should return access denied error", func() { + _, err := service.GetFolderById(1) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When get folder by uid should return access denied error", func() { + _, err := service.GetFolderByUid("uid") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When creating folder should return access denied error", func() { + err := service.CreateFolder(&models.CreateFolderCommand{ + Title: "Folder", + }) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When updating folder should return access denied error", func() { + err := service.UpdateFolder("uid", &models.UpdateFolderCommand{ + Uid: "uid", + Title: "Folder", + }) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When deleting folder by uid should return access denied error", func() { + _, err := service.DeleteFolder("uid") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has permission to save", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + + dash := models.NewDashboardFolder("Folder") + dash.Id = 1 + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = dash + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.SaveDashboardCommand) error { + cmd.Result = dash + return nil + }) + + bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error { + return nil + }) + + Convey("When creating folder should not return access denied error", func() { + err := service.CreateFolder(&models.CreateFolderCommand{ + Title: "Folder", + }) + So(err, ShouldBeNil) + }) + + Convey("When updating folder should not return access denied error", func() { + err := service.UpdateFolder("uid", &models.UpdateFolderCommand{ + Uid: "uid", + Title: "Folder", + }) + So(err, ShouldBeNil) + }) + + Convey("When deleting folder by uid should not return access denied error", func() { + _, err := service.DeleteFolder("uid") + So(err, ShouldBeNil) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has permission to view", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) + + dashFolder := models.NewDashboardFolder("Folder") + dashFolder.Id = 1 + dashFolder.Uid = "uid-abc" + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = dashFolder + return nil + }) + + Convey("When get folder by id should return folder", func() { + f, _ := service.GetFolderById(1) + So(f.Id, ShouldEqual, dashFolder.Id) + So(f.Uid, ShouldEqual, dashFolder.Uid) + So(f.Title, ShouldEqual, dashFolder.Title) + }) + + Convey("When get folder by uid should return folder", func() { + f, _ := service.GetFolderByUid("uid") + So(f.Id, ShouldEqual, dashFolder.Id) + So(f.Uid, ShouldEqual, dashFolder.Uid) + So(f.Title, ShouldEqual, dashFolder.Title) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Should map errors correct", func() { + testCases := []struct { + ActualError error + ExpectedError error + }{ + {ActualError: models.ErrDashboardTitleEmpty, ExpectedError: models.ErrFolderTitleEmpty}, + {ActualError: models.ErrDashboardUpdateAccessDenied, ExpectedError: models.ErrFolderAccessDenied}, + {ActualError: models.ErrDashboardWithSameNameInFolderExists, ExpectedError: models.ErrFolderSameNameExists}, + {ActualError: models.ErrDashboardWithSameUIDExists, ExpectedError: models.ErrFolderWithSameUIDExists}, + {ActualError: models.ErrDashboardVersionMismatch, ExpectedError: models.ErrFolderVersionMismatch}, + {ActualError: models.ErrDashboardNotFound, ExpectedError: models.ErrFolderNotFound}, + {ActualError: models.ErrDashboardFailedGenerateUniqueUid, ExpectedError: models.ErrFolderFailedGenerateUniqueUid}, + {ActualError: models.ErrDashboardInvalidUid, ExpectedError: models.ErrDashboardInvalidUid}, + } + + for _, tc := range testCases { + actualError := toFolderError(tc.ActualError) + if actualError != tc.ExpectedError { + t.Errorf("For error '%s' expected error '%s', actual '%s'", tc.ActualError, tc.ExpectedError, actualError) + } + } + }) + }) +} diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 98db5449182..811b38cac86 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -1,12 +1,19 @@ package guardian import ( + "errors" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) +var ( + ErrGuardianPermissionExists = errors.New("Permission already exists") + ErrGuardianOverride = errors.New("You can only override a permission to be higher") +) + // DashboardGuardian to be used for guard against operations without access on dashboard and acl type DashboardGuardian interface { CanSave() (bool, error) @@ -119,14 +126,51 @@ func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.D } func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { - if g.user.OrgRole == m.ROLE_ADMIN { - return true, nil + acl := []*m.DashboardAclInfoDTO{} + adminRole := m.ROLE_ADMIN + everyoneWithAdminRole := &m.DashboardAclInfoDTO{DashboardId: g.dashId, UserId: 0, TeamId: 0, Role: &adminRole, Permission: m.PERMISSION_ADMIN} + + // validate that duplicate permissions don't exists + for _, p := range updatePermissions { + aclItem := &m.DashboardAclInfoDTO{DashboardId: p.DashboardId, UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission} + if aclItem.IsDuplicateOf(everyoneWithAdminRole) { + return false, ErrGuardianPermissionExists + } + + for _, a := range acl { + if a.IsDuplicateOf(aclItem) { + return false, ErrGuardianPermissionExists + } + } + + acl = append(acl, aclItem) } - acl := []*m.DashboardAclInfoDTO{} + existingPermissions, err := g.GetAcl() + if err != nil { + return false, err + } - for _, p := range updatePermissions { - acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission}) + // validate overridden permissions to be higher + for _, a := range acl { + for _, existingPerm := range existingPermissions { + // handle default permissions + if existingPerm.DashboardId == -1 { + existingPerm.DashboardId = g.dashId + } + + if a.DashboardId == existingPerm.DashboardId { + continue + } + + if a.IsDuplicateOf(existingPerm) && a.Permission <= existingPerm.Permission { + return false, ErrGuardianOverride + } + } + } + + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil } return g.checkAcl(permission, acl) @@ -143,6 +187,13 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) { return nil, err } + for _, a := range query.Result { + // handle default permissions + if a.DashboardId == -1 { + a.DashboardId = g.dashId + } + } + g.acl = query.Result return g.acl, nil } @@ -158,3 +209,54 @@ func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) { g.groups = query.Result return query.Result, err } + +type FakeDashboardGuardian struct { + DashId int64 + OrgId int64 + User *m.SignedInUser + CanSaveValue bool + CanEditValue bool + CanViewValue bool + CanAdminValue bool + HasPermissionValue bool + CheckPermissionBeforeUpdateValue bool + CheckPermissionBeforeUpdateError error + GetAclValue []*m.DashboardAclInfoDTO +} + +func (g *FakeDashboardGuardian) CanSave() (bool, error) { + return g.CanSaveValue, nil +} + +func (g *FakeDashboardGuardian) CanEdit() (bool, error) { + return g.CanEditValue, nil +} + +func (g *FakeDashboardGuardian) CanView() (bool, error) { + return g.CanViewValue, nil +} + +func (g *FakeDashboardGuardian) CanAdmin() (bool, error) { + return g.CanAdminValue, nil +} + +func (g *FakeDashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) { + return g.HasPermissionValue, nil +} + +func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { + return g.CheckPermissionBeforeUpdateValue, g.CheckPermissionBeforeUpdateError +} + +func (g *FakeDashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { + return g.GetAclValue, nil +} + +func MockDashboardGuardian(mock *FakeDashboardGuardian) { + New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian { + mock.OrgId = orgId + mock.DashId = dashId + mock.User = user + return mock + } +} diff --git a/pkg/services/guardian/guardian_test.go b/pkg/services/guardian/guardian_test.go new file mode 100644 index 00000000000..bb7e6bd1a72 --- /dev/null +++ b/pkg/services/guardian/guardian_test.go @@ -0,0 +1,711 @@ +package guardian + +import ( + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/bus" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGuardian(t *testing.T) { + Convey("Guardian permission tests", t, func() { + orgRoleScenario("Given user has admin org role", m.ROLE_ADMIN, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + + Convey("When trying to update permissions", func() { + Convey("With duplicate user permissions should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With duplicate team permissions should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With duplicate everyone with editor role permission should return error", func() { + r := m.ROLE_EDITOR + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With duplicate everyone with viewer role permission should return error", func() { + r := m.ROLE_VIEWER + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + + Convey("With everyone with admin role permission should return error", func() { + r := m.ROLE_ADMIN + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianPermissionExists) + }) + }) + + Convey("Given default permissions", func() { + editor := m.ROLE_EDITOR + viewer := m.ROLE_VIEWER + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: -1, Role: &editor, Permission: m.PERMISSION_EDIT}, + {OrgId: 1, DashboardId: -1, Role: &viewer, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions without everyone with role editor can edit should be allowed", func() { + r := m.ROLE_VIEWER + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions without everyone with role viewer can view should be allowed", func() { + r := m.ROLE_EDITOR + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + }) + + Convey("Given parent folder has user admin permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with edit user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has user edit permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has user view permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit user permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with view user permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has team admin permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with edit team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has team edit permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with view team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has team view permission", func() { + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with edit team permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with view team permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has editor role with edit permission", func() { + r := m.ROLE_EDITOR + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with everyone with editor role can admin permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with everyone with editor role can edit permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + + Convey("When trying to update dashboard permissions with everyone with editor role can view permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + + Convey("Given parent folder has editor role with view permission", func() { + r := m.ROLE_EDITOR + existingPermissions := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_VIEW}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = existingPermissions + return nil + }) + + Convey("When trying to update dashboard permissions with everyone with viewer role can admin permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with everyone with viewer role can edit permission should be allowed", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeTrue) + }) + + Convey("When trying to update dashboard permissions with everyone with viewer role can view permission should return error", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW}, + } + _, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(err, ShouldEqual, ErrGuardianOverride) + }) + }) + }) + + orgRoleScenario("Given user has editor org role", m.ROLE_EDITOR, func(sc *scenarioContext) { + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + teamWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + teamWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + teamWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + Convey("When trying to update permissions should return false", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeFalse) + }) + }) + + orgRoleScenario("Given user has viewer org role", m.ROLE_VIEWER, func(sc *scenarioContext) { + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeFalse) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeTrue) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeTrue) + So(canSave, ShouldBeTrue) + So(canView, ShouldBeTrue) + }) + + userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) { + canAdmin, _ := sc.g.CanAdmin() + canEdit, _ := sc.g.CanEdit() + canSave, _ := sc.g.CanSave() + canView, _ := sc.g.CanView() + So(canAdmin, ShouldBeFalse) + So(canEdit, ShouldBeFalse) + So(canSave, ShouldBeFalse) + So(canView, ShouldBeTrue) + }) + + Convey("When trying to update permissions should return false", func() { + p := []*m.DashboardAcl{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW}, + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}, + } + ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p) + So(ok, ShouldBeFalse) + }) + }) + }) +} + +type scenarioContext struct { + g DashboardGuardian +} + +type scenarioFunc func(c *scenarioContext) + +func orgRoleScenario(desc string, role m.RoleType, fn scenarioFunc) { + user := &m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: role, + } + guard := New(1, 1, user) + sc := &scenarioContext{ + g: guard, + } + + Convey(desc, func() { + fn(sc) + }) +} + +func permissionScenario(desc string, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) { + bus.ClearBusHandlers() + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = permissions + return nil + }) + + teams := []*m.Team{} + + for _, p := range permissions { + if p.TeamId > 0 { + teams = append(teams, &m.Team{Id: p.TeamId}) + } + } + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = teams + return nil + }) + + Convey(desc, func() { + fn(sc) + }) +} + +func userWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { + p := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: 1, Permission: permission}, + } + permissionScenario(fmt.Sprintf("and user has permission to %s item", permission), sc, p, fn) +} + +func teamWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { + p := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, TeamId: 1, Permission: permission}, + } + permissionScenario(fmt.Sprintf("and team has permission to %s item", permission), sc, p, fn) +} + +func everyoneWithRoleScenario(role m.RoleType, permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) { + p := []*m.DashboardAclInfoDTO{ + {OrgId: 1, DashboardId: 1, UserId: -1, Role: &role, Permission: permission}, + } + permissionScenario(fmt.Sprintf("and everyone with %s role can %s item", role, permission), sc, p, fn) +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 8516e7c46e7..5ee34183628 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -37,6 +37,12 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { dash := cmd.GetDashboardModel() + userId := cmd.UserId + + if userId == 0 { + userId = -1 + } + if dash.Id > 0 { var existing m.Dashboard dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) @@ -76,17 +82,23 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { if dash.Id == 0 { dash.SetVersion(1) + dash.Created = time.Now() + dash.CreatedBy = userId + dash.Updated = time.Now() + dash.UpdatedBy = userId metrics.M_Api_Dashboard_Insert.Inc() affectedRows, err = sess.Insert(dash) } else { - v := dash.Version - v++ - dash.SetVersion(v) + dash.SetVersion(dash.Version + 1) if !cmd.UpdatedAt.IsZero() { dash.Updated = cmd.UpdatedAt + } else { + dash.Updated = time.Now() } + dash.UpdatedBy = userId + affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) } @@ -514,7 +526,7 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash } if !folderExists { - return m.ErrFolderNotFound + return m.ErrDashboardFolderNotFound } } diff --git a/pkg/services/sqlstore/dashboard_service_integration_test.go b/pkg/services/sqlstore/dashboard_service_integration_test.go index e4bf93c2f58..d005270c33c 100644 --- a/pkg/services/sqlstore/dashboard_service_integration_test.go +++ b/pkg/services/sqlstore/dashboard_service_integration_test.go @@ -142,9 +142,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, 0) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) @@ -165,9 +165,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, otherSavedFolder.Id) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) @@ -189,9 +189,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInGeneralFolder.Id) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInGeneralFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) @@ -213,9 +213,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInFolder.Id) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) }) @@ -363,7 +363,7 @@ func TestIntegratedDashboardService(t *testing.T) { Convey("It should result in folder not found error", func() { So(err, ShouldNotBeNil) - So(err, ShouldEqual, models.ErrFolderNotFound) + So(err, ShouldEqual, models.ErrDashboardFolderNotFound) }) }) @@ -785,68 +785,16 @@ func TestIntegratedDashboardService(t *testing.T) { }) } -func mockDashboardGuardian(mock *mockDashboardGuarder) { - guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian { - mock.orgId = orgId - mock.dashId = dashId - mock.user = user - return mock - } -} - -type mockDashboardGuarder struct { - dashId int64 - orgId int64 - user *models.SignedInUser - canSave bool - canSaveCallCounter int - canEdit bool - canView bool - canAdmin bool - hasPermission bool - checkPermissionBeforeRemove bool - checkPermissionBeforeUpdate bool -} - -func (g *mockDashboardGuarder) CanSave() (bool, error) { - g.canSaveCallCounter++ - return g.canSave, nil -} - -func (g *mockDashboardGuarder) CanEdit() (bool, error) { - return g.canEdit, nil -} - -func (g *mockDashboardGuarder) CanView() (bool, error) { - return g.canView, nil -} - -func (g *mockDashboardGuarder) CanAdmin() (bool, error) { - return g.canAdmin, nil -} - -func (g *mockDashboardGuarder) HasPermission(permission models.PermissionType) (bool, error) { - return g.hasPermission, nil -} - -func (g *mockDashboardGuarder) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) { - return g.checkPermissionBeforeUpdate, nil -} - -func (g *mockDashboardGuarder) GetAcl() ([]*models.DashboardAclInfoDTO, error) { - return nil, nil -} - type scenarioContext struct { - dashboardGuardianMock *mockDashboardGuarder + dashboardGuardianMock *guardian.FakeDashboardGuardian } type scenarioFunc func(c *scenarioContext) -func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) { +func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) { Convey(desc, func() { origNewDashboardGuardian := guardian.New - mockDashboardGuardian(mock) + guardian.MockDashboardGuardian(mock) sc := &scenarioContext{ dashboardGuardianMock: mock, @@ -861,15 +809,15 @@ func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scena } type dashboardPermissionScenarioContext struct { - dashboardGuardianMock *mockDashboardGuarder + dashboardGuardianMock *guardian.FakeDashboardGuardian } type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext) -func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn dashboardPermissionScenarioFunc) { +func dashboardPermissionScenario(desc string, mock *guardian.FakeDashboardGuardian, fn dashboardPermissionScenarioFunc) { Convey(desc, func() { origNewDashboardGuardian := guardian.New - mockDashboardGuardian(mock) + guardian.MockDashboardGuardian(mock) sc := &dashboardPermissionScenarioContext{ dashboardGuardianMock: mock, @@ -884,8 +832,8 @@ func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn das } func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) { - mock := &mockDashboardGuarder{ - canSave: canSave, + mock := &guardian.FakeDashboardGuardian{ + CanSaveValue: canSave, } dashboardPermissionScenario(desc, mock, fn) } @@ -902,10 +850,10 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error { return err } -func dashboardServiceScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) { +func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) { Convey(desc, func() { origNewDashboardGuardian := guardian.New - mockDashboardGuardian(mock) + guardian.MockDashboardGuardian(mock) sc := &scenarioContext{ dashboardGuardianMock: mock, diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 0ef7f99da67..9e82bbb2c83 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -16,20 +16,23 @@ func init() { bus.AddHandler("sql", DeleteExpiredSnapshots) } +// DeleteExpiredSnapshots removes snapshots with old expiry dates. +// SnapShotRemoveExpired is deprecated and should be removed in the future. +// Snapshot expiry is decided by the user when they share the snapshot. func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error { return inTransaction(func(sess *DBSession) error { - var expiredCount int64 = 0 - - if setting.SnapShotRemoveExpired { - deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?" - expiredResponse, err := x.Exec(deleteExpiredSql, time.Now) - if err != nil { - return err - } - expiredCount, _ = expiredResponse.RowsAffected() + if !setting.SnapShotRemoveExpired { + sqlog.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") + return nil } - sqlog.Debug("Deleted old/expired snaphots", "expired", expiredCount) + deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?" + expiredResponse, err := sess.Exec(deleteExpiredSql, time.Now()) + if err != nil { + return err + } + cmd.DeletedRows, _ = expiredResponse.RowsAffected() + return nil }) } @@ -72,7 +75,7 @@ func DeleteDashboardSnapshot(cmd *m.DeleteDashboardSnapshotCommand) error { } func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { - snapshot := m.DashboardSnapshot{Key: query.Key} + snapshot := m.DashboardSnapshot{Key: query.Key, DeleteKey: query.DeleteKey} has, err := x.Get(&snapshot) if err != nil { @@ -85,6 +88,8 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { return nil } +// SearchDashboardSnapshots returns a list of all snapshots for admins +// for other roles, it returns snapshots created by the user func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error { var snapshots = make(m.DashboardSnapshotsList, 0) @@ -95,7 +100,16 @@ func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error { sess.Where("name LIKE ?", query.Name) } - sess.Where("org_id = ?", query.OrgId) + // admins can see all snapshots, everyone else can only see their own snapshots + if query.SignedInUser.OrgRole == m.ROLE_ADMIN { + sess.Where("org_id = ?", query.OrgId) + } else if !query.SignedInUser.IsAnonymous { + sess.Where("org_id = ? AND user_id = ?", query.OrgId, query.SignedInUser.UserId) + } else { + query.Result = snapshots + return nil + } + err := sess.Find(&snapshots) query.Result = snapshots return err diff --git a/pkg/services/sqlstore/dashboard_snapshot_test.go b/pkg/services/sqlstore/dashboard_snapshot_test.go index 50375088b4b..2081cbf6194 100644 --- a/pkg/services/sqlstore/dashboard_snapshot_test.go +++ b/pkg/services/sqlstore/dashboard_snapshot_test.go @@ -2,11 +2,14 @@ package sqlstore import ( "testing" + "time" + "github.com/go-xorm/xorm" . "github.com/smartystreets/goconvey/convey" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" ) func TestDashboardSnapshotDBAccess(t *testing.T) { @@ -14,17 +17,19 @@ func TestDashboardSnapshotDBAccess(t *testing.T) { Convey("Testing DashboardSnapshot data access", t, func() { InitTestDB(t) - Convey("Given saved snaphot", func() { + Convey("Given saved snapshot", func() { cmd := m.CreateDashboardSnapshotCommand{ Key: "hej", Dashboard: simplejson.NewFromAny(map[string]interface{}{ "hello": "mupp", }), + UserId: 1000, + OrgId: 1, } err := CreateDashboardSnapshot(&cmd) So(err, ShouldBeNil) - Convey("Should be able to get snaphot by key", func() { + Convey("Should be able to get snapshot by key", func() { query := m.GetDashboardSnapshotQuery{Key: "hej"} err = GetDashboardSnapshot(&query) So(err, ShouldBeNil) @@ -33,6 +38,135 @@ func TestDashboardSnapshotDBAccess(t *testing.T) { So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp") }) + Convey("And the user has the admin role", func() { + Convey("Should return all the snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 1) + }) + }) + + Convey("And the user has the editor role and has created a snapshot", func() { + Convey("Should return all the snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 1000}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 1) + }) + }) + + Convey("And the user has the editor role and has not created any snapshot", func() { + Convey("Should not return any snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 2}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 0) + }) + }) + + Convey("And the user is anonymous", func() { + cmd := m.CreateDashboardSnapshotCommand{ + Key: "strangesnapshotwithuserid0", + DeleteKey: "adeletekey", + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "hello": "mupp", + }), + UserId: 0, + OrgId: 1, + } + err := CreateDashboardSnapshot(&cmd) + So(err, ShouldBeNil) + + Convey("Should not return any snapshots", func() { + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, IsAnonymous: true, UserId: 0}, + } + err := SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(query.Result, ShouldNotBeNil) + So(len(query.Result), ShouldEqual, 0) + }) + }) }) }) } + +func TestDeleteExpiredSnapshots(t *testing.T) { + Convey("Testing dashboard snapshots clean up", t, func() { + x := InitTestDB(t) + + setting.SnapShotRemoveExpired = true + + notExpiredsnapshot := createTestSnapshot(x, "key1", 1000) + createTestSnapshot(x, "key2", -1000) + createTestSnapshot(x, "key3", -1000) + + Convey("Clean up old dashboard snapshots", func() { + err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{}) + So(err, ShouldBeNil) + + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}, + } + err = SearchDashboardSnapshots(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key) + }) + + Convey("Don't delete anything if there are no expired snapshots", func() { + err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{}) + So(err, ShouldBeNil) + + query := m.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN}, + } + SearchDashboardSnapshots(&query) + + So(len(query.Result), ShouldEqual, 1) + }) + }) +} + +func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardSnapshot { + cmd := m.CreateDashboardSnapshotCommand{ + Key: key, + DeleteKey: "delete" + key, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "hello": "mupp", + }), + UserId: 1000, + OrgId: 1, + Expires: expires, + } + err := CreateDashboardSnapshot(&cmd) + So(err, ShouldBeNil) + + // Set expiry date manually - to be able to create expired snapshots + expireDate := time.Now().Add(time.Second * time.Duration(expires)) + _, err = x.Exec("update dashboard_snapshot set expires = ? where "+dialect.Quote("key")+" = ?", expireDate, key) + So(err, ShouldBeNil) + + return cmd.Result +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index 51a2d4eb64e..e0a73b9a49a 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -3,6 +3,7 @@ package sqlstore import ( "fmt" "testing" + "time" "github.com/go-xorm/xorm" "github.com/grafana/grafana/pkg/components/simplejson" @@ -124,6 +125,24 @@ func TestDashboardDataAccess(t *testing.T) { generateNewUid = util.GenerateShortUid }) + Convey("Should be able to create dashboard", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "folderId", + "tags": []interface{}{}, + }), + UserId: 100, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + So(cmd.Result.CreatedBy, ShouldEqual, 100) + So(cmd.Result.Created.IsZero(), ShouldBeFalse) + So(cmd.Result.UpdatedBy, ShouldEqual, 100) + So(cmd.Result.Updated.IsZero(), ShouldBeFalse) + }) + Convey("Should be able to update dashboard by id and remove folderId", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, @@ -134,6 +153,7 @@ func TestDashboardDataAccess(t *testing.T) { }), Overwrite: true, FolderId: 2, + UserId: 100, } err := SaveDashboard(&cmd) @@ -149,6 +169,7 @@ func TestDashboardDataAccess(t *testing.T) { }), FolderId: 0, Overwrite: true, + UserId: 100, } err = SaveDashboard(&cmd) @@ -162,6 +183,10 @@ func TestDashboardDataAccess(t *testing.T) { err = GetDashboard(&query) So(err, ShouldBeNil) So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy) + So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second)) + So(query.Result.UpdatedBy, ShouldEqual, 100) + So(query.Result.Updated.IsZero(), ShouldBeFalse) }) Convey("Should be able to delete a dashboard folder and its children", func() { diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go index 49c35397094..547f62628f3 100644 --- a/pkg/services/sqlstore/dashboard_version.go +++ b/pkg/services/sqlstore/dashboard_version.go @@ -69,7 +69,6 @@ func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error { func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error { return inTransaction(func(sess *DBSession) error { - expiredCount := int64(0) versions := []DashboardVersionExp{} versionsToKeep := setting.DashboardVersionsToKeep @@ -98,8 +97,7 @@ func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error { if err != nil { return err } - expiredCount, _ = expiredResponse.RowsAffected() - sqlog.Debug("Deleted old/expired dashboard versions", "expired", expiredCount) + cmd.DeletedRows, _ = expiredResponse.RowsAffected() } return nil diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 6ce80a69957..6099388f668 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -88,7 +88,6 @@ var ( ExternalSnapshotUrl string ExternalSnapshotName string ExternalEnabled bool - SnapShotTTLDays int SnapShotRemoveExpired bool // Dashboard history @@ -523,7 +522,6 @@ func NewConfigContext(args *CommandLineArgs) error { ExternalSnapshotName = snapshots.Key("external_snapshot_name").String() ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) - SnapShotTTLDays = snapshots.Key("snapshot_TTL_days").MustInt(90) // read dashboard settings dashboards := Cfg.Section("dashboards") diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index 6f00cd2cd9e..815c684cf03 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -195,10 +195,9 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { - Id int `json:"id"` - Login string `json:"login"` - Email string `json:"email"` - OrganizationsUrl string `json:"organizations_url"` + Id int `json:"id"` + Login string `json:"login"` + Email string `json:"email"` } response, err := HttpGet(client, s.apiUrl) @@ -217,11 +216,13 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi Email: data.Email, } + organizationsUrl := fmt.Sprintf(s.apiUrl + "/orgs") + if !s.IsTeamMember(client) { return nil, ErrMissingTeamMembership } - if !s.IsOrganizationMember(client, data.OrganizationsUrl) { + if !s.IsOrganizationMember(client, organizationsUrl) { return nil, ErrMissingOrganizationMembership } diff --git a/public/app/containers/ManageDashboards/FolderPermissions.tsx b/public/app/containers/ManageDashboards/FolderPermissions.tsx index 93cfe9494c2..9c82db1c18c 100644 --- a/public/app/containers/ManageDashboards/FolderPermissions.tsx +++ b/public/app/containers/ManageDashboards/FolderPermissions.tsx @@ -26,7 +26,7 @@ export class FolderPermissions extends Component { loadStore() { const { nav, folder, view } = this.props; return folder.load(view.routeParams.get('uid') as string).then(res => { - view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {}); + view.updatePathAndQuery(`${res.url}/permissions`, {}, {}); return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions'); }); } diff --git a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx index 3630bf534ba..bed3d569bcc 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx @@ -9,17 +9,14 @@ describe('FolderSettings', () => { let page; beforeAll(() => { - backendSrv.getDashboardByUid.mockReturnValue( + backendSrv.getFolderByUid.mockReturnValue( Promise.resolve({ - dashboard: { - id: 1, - title: 'Folder Name', - uid: 'uid-str', - }, - meta: { - url: '/dashboards/f/uid/folder-name', - canSave: true, - }, + id: 1, + uid: 'uid', + title: 'Folder Name', + url: '/dashboards/f/uid/folder-name', + canSave: true, + version: 1, }) ); diff --git a/public/app/containers/ManageDashboards/FolderSettings.tsx b/public/app/containers/ManageDashboards/FolderSettings.tsx index a6349764a14..586a8f05b4c 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.tsx @@ -10,7 +10,6 @@ import appEvents from 'app/core/app_events'; @observer export class FolderSettings extends React.Component { formSnapshot: any; - dashboard: any; constructor(props) { super(props); @@ -22,9 +21,7 @@ export class FolderSettings extends React.Component { return folder.load(view.routeParams.get('uid') as string).then(res => { this.formSnapshot = getSnapshot(folder); - this.dashboard = res.dashboard; - - view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {}); + view.updatePathAndQuery(`${res.url}/settings`, {}, {}); return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); }); @@ -51,7 +48,7 @@ export class FolderSettings extends React.Component { const { nav, folder, view } = this.props; folder - .saveFolder(this.dashboard, { overwrite: false }) + .saveFolder({ overwrite: false }) .then(newUrl => { view.updatePathAndQuery(newUrl, {}, {}); @@ -61,7 +58,7 @@ export class FolderSettings extends React.Component { .then(() => { return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); }) - .catch(this.handleSaveFolderError); + .catch(this.handleSaveFolderError.bind(this)); } delete(evt) { @@ -79,7 +76,7 @@ export class FolderSettings extends React.Component { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - return this.props.folder.deleteFolder().then(() => { + return folder.deleteFolder().then(() => { appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]); view.updatePathAndQuery('dashboards', '', ''); }); @@ -91,6 +88,8 @@ export class FolderSettings extends React.Component { if (err.data && err.data.status === 'version-mismatch') { err.isHandled = true; + const { nav, folder, view } = this.props; + appEvents.emit('confirm-modal', { title: 'Conflict', text: 'Someone else has updated this folder.', @@ -98,16 +97,20 @@ export class FolderSettings extends React.Component { yesText: 'Save & Overwrite', icon: 'fa-warning', onConfirm: () => { - this.props.folder.saveFolder(this.dashboard, { overwrite: true }); + folder + .saveFolder({ overwrite: true }) + .then(newUrl => { + view.updatePathAndQuery(newUrl, {}, {}); + + appEvents.emit('dashboard-saved'); + appEvents.emit('alert-success', ['Folder saved']); + }) + .then(() => { + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); + }); }, }); } - - if (err.data && err.data.status === 'name-exists') { - err.isHandled = true; - - appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']); - } } render() { diff --git a/public/app/core/components/Permissions/AddPermissions.jest.tsx b/public/app/core/components/Permissions/AddPermissions.jest.tsx index 48ff20a16aa..fe97c4c7e62 100644 --- a/public/app/core/components/Permissions/AddPermissions.jest.tsx +++ b/public/app/core/components/Permissions/AddPermissions.jest.tsx @@ -17,7 +17,7 @@ describe('AddPermissions', () => { ]) ); - backendSrv.post = jest.fn(); + backendSrv.post = jest.fn(() => Promise.resolve({})); store = RootStore.create( {}, @@ -53,7 +53,7 @@ describe('AddPermissions', () => { wrapper.find('form').simulate('submit', { preventDefault() {} }); expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); }); @@ -80,7 +80,7 @@ describe('AddPermissions', () => { wrapper.find('form').simulate('submit', { preventDefault() {} }); expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); }); diff --git a/public/app/core/components/Permissions/AddPermissions.tsx b/public/app/core/components/Permissions/AddPermissions.tsx index 94afa7c1180..07ccfdbbef5 100644 --- a/public/app/core/components/Permissions/AddPermissions.tsx +++ b/public/app/core/components/Permissions/AddPermissions.tsx @@ -135,14 +135,6 @@ class AddPermissions extends Component { - {permissions.error ? ( -
- - - {permissions.error} - -
- ) : null} ); } diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts index c2f6f213dd3..a544fc89854 100644 --- a/public/app/core/components/help/help.ts +++ b/public/app/core/components/help/help.ts @@ -33,10 +33,6 @@ export class HelpCtrl { { keys: ['p', 's'], description: 'Open Panel Share Modal' }, { keys: ['p', 'r'], description: 'Remove Panel' }, ], - 'Focused Row': [ - { keys: ['r', 'c'], description: 'Collapse Row' }, - { keys: ['r', 'r'], description: 'Remove Row' }, - ], 'Time Range': [ { keys: ['t', 'z'], description: 'Zoom out time range' }, { diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index 4a8ab32ab7d..545119a80d7 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -78,8 +78,8 @@ export class ManageDashboardsCtrl { return; } - return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => { - this.canSave = dash.meta.canSave; + return this.backendSrv.getFolderByUid(this.folderUid).then(folder => { + this.canSave = folder.canSave; }); }); } @@ -173,48 +173,13 @@ export class ManageDashboardsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - const foldersAndDashboards = data.folders.concat(data.dashboards); - this.deleteFoldersAndDashboards(foldersAndDashboards); + this.deleteFoldersAndDashboards(data.folders, data.dashboards); }, }); } - private deleteFoldersAndDashboards(uids) { - this.backendSrv.deleteDashboards(uids).then(result => { - const folders = _.filter(result, dash => dash.meta.isFolder); - const folderCount = folders.length; - const dashboards = _.filter(result, dash => !dash.meta.isFolder); - const dashCount = dashboards.length; - - if (result.length > 0) { - let header; - let msg; - - if (folderCount > 0 && dashCount > 0) { - header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`; - msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `; - msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; - } else if (folderCount > 0) { - header = `Folder${folderCount === 1 ? '' : 's'} Deleted`; - - if (folderCount === 1) { - msg = `${folders[0].dashboard.title} has been deleted`; - } else { - msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`; - } - } else if (dashCount > 0) { - header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`; - - if (dashCount === 1) { - msg = `${dashboards[0].dashboard.title} has been deleted`; - } else { - msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; - } - } - - appEvents.emit('alert-success', [header, msg]); - } - + private deleteFoldersAndDashboards(folderUids, dashboardUids) { + this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => { this.refreshList(); }); } diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 0a8b305ea53..8b7ca518e8b 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -221,14 +221,18 @@ export class BackendSrv { return this.get('/api/search', query); } - getDashboard(type, slug) { - return this.get('/api/dashboards/' + type + '/' + slug); + getDashboardBySlug(slug) { + return this.get(`/api/dashboards/db/${slug}`); } getDashboardByUid(uid: string) { return this.get(`/api/dashboards/uid/${uid}`); } + getFolderByUid(uid: string) { + return this.get(`/api/folders/${uid}`); + } + saveDashboard(dash, options) { options = options || {}; @@ -240,55 +244,41 @@ export class BackendSrv { }); } - createDashboardFolder(name) { - const dash = { - schemaVersion: 16, - title: name.trim(), - editable: true, - panels: [], - }; - - return this.post('/api/dashboards/db/', { - dashboard: dash, - isFolder: true, - overwrite: false, - }).then(res => { - return this.getDashboard('db', res.slug); - }); + createFolder(payload: any) { + return this.post('/api/folders', payload); } - saveFolder(dash, options) { + updateFolder(folder, options) { options = options || {}; - return this.post('/api/dashboards/db/', { - dashboard: dash, - isFolder: true, + return this.put(`/api/folders/${folder.uid}`, { + title: folder.title, + version: folder.version, overwrite: options.overwrite === true, - message: options.message || '', }); } - deleteDashboard(uid) { - let deferred = this.$q.defer(); - - this.getDashboardByUid(uid).then(fullDash => { - this.delete(`/api/dashboards/uid/${uid}`) - .then(() => { - deferred.resolve(fullDash); - }) - .catch(err => { - deferred.reject(err); - }); - }); - - return deferred.promise; + deleteFolder(uid: string, showSuccessAlert) { + return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true }); } - deleteDashboards(dashboardUids) { + deleteDashboard(uid, showSuccessAlert) { + return this.request({ + method: 'DELETE', + url: `/api/dashboards/uid/${uid}`, + showSuccessAlert: showSuccessAlert === true, + }); + } + + deleteFoldersAndDashboards(folderUids, dashboardUids) { const tasks = []; - for (let uid of dashboardUids) { - tasks.push(this.createTask(this.deleteDashboard.bind(this), true, uid)); + for (let folderUid of folderUids) { + tasks.push(this.createTask(this.deleteFolder.bind(this), true, folderUid, true)); + } + + for (let dashboardUid of dashboardUids) { + tasks.push(this.createTask(this.deleteDashboard.bind(this), true, dashboardUid, true)); } return this.executeInOrder(tasks, []); diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 6f7a48f61d5..1658d74be0a 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -171,8 +171,9 @@ export class KeybindingSrv { // delete panel this.bind('p r', () => { if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { - var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); - panelInfo.row.removePanel(panelInfo.panel); + this.$rootScope.appEvent('panel-remove', { + panelId: dashboard.meta.focusPanelId, + }); dashboard.meta.focusPanelId = 0; } }); @@ -192,36 +193,14 @@ export class KeybindingSrv { } }); - // delete row - this.bind('r r', () => { - if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { - var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); - dashboard.removeRow(panelInfo.row); - dashboard.meta.focusPanelId = 0; - } - }); - - // collapse row - this.bind('r c', () => { - if (dashboard.meta.focusPanelId) { - var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); - panelInfo.row.toggleCollapse(); - dashboard.meta.focusPanelId = 0; - } - }); - // collapse all rows this.bind('d shift+c', () => { - for (let row of dashboard.rows) { - row.collapse = true; - } + dashboard.collapseRows(); }); // expand all rows this.bind('d shift+e', () => { - for (let row of dashboard.rows) { - row.collapse = false; - } + dashboard.expandRows(); }); this.bind('d n', e => { diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 11dccc32907..329b415f1fe 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -463,6 +463,15 @@ kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2); kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3); kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3); +// Hash Rate +kbn.valueFormats.Hs = kbn.formatBuilders.decimalSIPrefix('H/s'); +kbn.valueFormats.KHs = kbn.formatBuilders.decimalSIPrefix('H/s', 1); +kbn.valueFormats.MHs = kbn.formatBuilders.decimalSIPrefix('H/s', 2); +kbn.valueFormats.GHs = kbn.formatBuilders.decimalSIPrefix('H/s', 3); +kbn.valueFormats.THs = kbn.formatBuilders.decimalSIPrefix('H/s', 4); +kbn.valueFormats.PHs = kbn.formatBuilders.decimalSIPrefix('H/s', 5); +kbn.valueFormats.EHs = kbn.formatBuilders.decimalSIPrefix('H/s', 6); + // Throughput kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops'); kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps'); @@ -878,6 +887,18 @@ kbn.getUnitFormats = function() { { text: 'gigabits/sec', value: 'Gbits' }, ], }, + { + text: 'hash rate', + submenu: [ + {text: 'hashes/sec', value: 'Hs'}, + {text: 'kilohashes/sec', value: 'KHs'}, + {text: 'megahashes/sec', value: 'MHs'}, + {text: 'gigahashes/sec', value: 'GHs'}, + {text: 'terahashes/sec', value: 'THs'}, + {text: 'petahashes/sec', value: 'PHs'}, + {text: 'exahashes/sec', value: 'EHs'}, + ], + }, { text: 'throughput', submenu: [ diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/dashboard/create_folder_ctrl.ts index 4b74d290e34..5c8bd276f76 100644 --- a/public/app/features/dashboard/create_folder_ctrl.ts +++ b/public/app/features/dashboard/create_folder_ctrl.ts @@ -18,9 +18,9 @@ export class CreateFolderCtrl { return; } - return this.backendSrv.createDashboardFolder(this.title).then(result => { + return this.backendSrv.createFolder({ title: this.title }).then(result => { appEvents.emit('alert-success', ['Folder Created', 'OK']); - this.$location.url(locationUtil.stripBaseFromUrl(result.meta.url)); + this.$location.url(locationUtil.stripBaseFromUrl(result.url)); }); } diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index 8b1c69ef7fe..94d0b18f157 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -3,6 +3,7 @@ import config from 'app/core/config'; import coreModule from 'app/core/core_module'; import { PanelContainer } from './dashgrid/PanelContainer'; import { DashboardModel } from './dashboard_model'; +import { PanelModel } from './panel_model'; export class DashboardCtrl implements PanelContainer { dashboard: DashboardModel; @@ -130,9 +131,47 @@ export class DashboardCtrl implements PanelContainer { return this; } + onRemovingPanel(evt, options) { + options = options || {}; + if (!options.panelId) { + return; + } + + var panelInfo = this.dashboard.getPanelInfoById(options.panelId); + this.removePanel(panelInfo.panel, true); + } + + removePanel(panel: PanelModel, ask: boolean) { + // confirm deletion + if (ask !== false) { + var text2, confirmText; + + if (panel.alert) { + text2 = 'Panel includes an alert rule, removing panel will also remove alert rule'; + confirmText = 'YES'; + } + + this.$scope.appEvent('confirm-modal', { + title: 'Remove Panel', + text: 'Are you sure you want to remove this panel?', + text2: text2, + icon: 'fa-trash', + confirmText: confirmText, + yesText: 'Remove', + onConfirm: () => { + this.removePanel(panel, false); + }, + }); + return; + } + + this.dashboard.removePanel(panel); + } + init(dashboard) { this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this)); this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this)); + this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this)); this.setupDashboard(dashboard); } } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index e35f8f0d430..3fa8ed9973a 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -524,6 +524,34 @@ export class DashboardModel { this.removePanel(row); } + expandRows() { + for (let i = 0; i < this.panels.length; i++) { + var panel = this.panels[i]; + + if (panel.type !== 'row') { + continue; + } + + if (panel.collapsed) { + this.toggleRow(panel); + } + } + } + + collapseRows() { + for (let i = 0; i < this.panels.length; i++) { + var panel = this.panels[i]; + + if (panel.type !== 'row') { + continue; + } + + if (!panel.collapsed) { + this.toggleRow(panel); + } + } + } + setPanelFocus(id) { this.meta.focusPanelId = id; } diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index b41d7b0583e..9d766fdfc3f 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -92,7 +92,7 @@ export class DashboardSrv { save(clone, options) { options = options || {}; - options.folderId = options.folderId || this.dash.meta.folderId || clone.folderId; + options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId; return this.backendSrv .saveDashboard(clone, options) diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 3f65c33c90d..03bf65afc6e 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactGridLayout from 'react-grid-layout'; +import ReactGridLayout from 'react-grid-layout-grafana'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { DashboardPanel } from './DashboardPanel'; import { DashboardModel } from '../dashboard_model'; @@ -50,7 +50,8 @@ function GridWrapper({ onResize={onResize} onResizeStop={onResizeStop} onDragStop={onDragStop} - onLayoutChange={onLayoutChange}> + onLayoutChange={onLayoutChange} + > {children} ); @@ -178,7 +179,7 @@ export class DashboardGrid extends React.Component { panelElements.push(
-
, + ); } @@ -196,7 +197,8 @@ export class DashboardGrid extends React.Component { onWidthChange={this.onWidthChange} onDragStop={this.onDragStop} onResize={this.onResize} - onResizeStop={this.onResizeStop}> + onResizeStop={this.onResizeStop} + > {this.renderPanels()} ); diff --git a/public/app/features/dashboard/folder_dashboards_ctrl.ts b/public/app/features/dashboard/folder_dashboards_ctrl.ts index 8ee942445ae..05cc420c489 100644 --- a/public/app/features/dashboard/folder_dashboards_ctrl.ts +++ b/public/app/features/dashboard/folder_dashboards_ctrl.ts @@ -14,7 +14,7 @@ export class FolderDashboardsCtrl { const loader = new FolderPageLoader(this.backendSrv); loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => { - const url = locationUtil.stripBaseFromUrl(folder.meta.url); + const url = locationUtil.stripBaseFromUrl(folder.url); if (url !== $location.path()) { $location.path(url).replace(); diff --git a/public/app/features/dashboard/folder_page_loader.ts b/public/app/features/dashboard/folder_page_loader.ts index 81d10068361..6842c61847e 100755 --- a/public/app/features/dashboard/folder_page_loader.ts +++ b/public/app/features/dashboard/folder_page_loader.ts @@ -36,16 +36,16 @@ export class FolderPageLoader { }, }; - return this.backendSrv.getDashboardByUid(uid).then(result => { - ctrl.folderId = result.dashboard.id; - const folderTitle = result.dashboard.title; - const folderUrl = result.meta.url; + return this.backendSrv.getFolderByUid(uid).then(folder => { + ctrl.folderId = folder.id; + const folderTitle = folder.title; + const folderUrl = folder.url; ctrl.navModel.main.text = folderTitle; const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards'); dashTab.url = folderUrl; - if (result.meta.canAdmin) { + if (folder.canAdmin) { const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions'); permTab.url = folderUrl + '/permissions'; @@ -55,7 +55,7 @@ export class FolderPageLoader { ctrl.navModel.main.children = [dashTab]; } - return result; + return folder; }); } } diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts index 0e5c22c4db2..cbf23e3ea4b 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -89,13 +89,13 @@ export class FolderPickerCtrl { evt.preventDefault(); } - return this.backendSrv.createDashboardFolder(this.newFolderName).then(result => { + return this.backendSrv.createFolder({ title: this.newFolderName }).then(result => { appEvents.emit('alert-success', ['Folder Created', 'OK']); this.closeCreateFolder(); this.folder = { - text: result.dashboard.title, - value: result.dashboard.id, + text: result.title, + value: result.id, }; this.onFolderChange(this.folder); }); diff --git a/public/app/features/dashboard/folder_settings_ctrl.ts b/public/app/features/dashboard/folder_settings_ctrl.ts index 004ba2efa9f..a847c29ac56 100644 --- a/public/app/features/dashboard/folder_settings_ctrl.ts +++ b/public/app/features/dashboard/folder_settings_ctrl.ts @@ -7,8 +7,7 @@ export class FolderSettingsCtrl { folderId: number; uid: string; canSave = false; - dashboard: any; - meta: any; + folder: any; title: string; hasChanged: boolean; @@ -23,10 +22,9 @@ export class FolderSettingsCtrl { $location.path(`${folder.meta.url}/settings`).replace(); } - this.dashboard = folder.dashboard; - this.meta = folder.meta; - this.canSave = folder.meta.canSave; - this.title = this.dashboard.title; + this.folder = folder; + this.canSave = this.folder.canSave; + this.title = this.folder.title; }); } } @@ -38,10 +36,10 @@ export class FolderSettingsCtrl { return; } - this.dashboard.title = this.title.trim(); + this.folder.title = this.title.trim(); return this.backendSrv - .updateDashboardFolder(this.dashboard, { overwrite: false }) + .updateFolder(this.folder) .then(result => { if (result.url !== this.$location.path()) { this.$location.url(result.url + '/settings'); @@ -54,7 +52,7 @@ export class FolderSettingsCtrl { } titleChanged() { - this.hasChanged = this.dashboard.title.toLowerCase() !== this.title.trim().toLowerCase(); + this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase(); } delete(evt) { @@ -69,8 +67,8 @@ export class FolderSettingsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - return this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => { - appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]); + return this.backendSrv.deleteFolder(this.uid).then(() => { + appEvents.emit('alert-success', ['Folder Deleted', `${this.folder.title} has been deleted`]); this.$location.url('dashboards'); }); }, @@ -88,15 +86,9 @@ export class FolderSettingsCtrl { yesText: 'Save & Overwrite', icon: 'fa-warning', onConfirm: () => { - this.backendSrv.updateDashboardFolder(this.dashboard, { overwrite: true }); + this.backendSrv.updateFolder(this.folder, { overwrite: true }); }, }); } - - if (err.data && err.data.status === 'name-exists') { - err.isHandled = true; - - appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']); - } } } diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/save_as_modal.ts index 15a3ce5dba7..60f6fb4c1da 100644 --- a/public/app/features/dashboard/save_as_modal.ts +++ b/public/app/features/dashboard/save_as_modal.ts @@ -22,6 +22,8 @@ const template = `
@@ -29,7 +31,7 @@ const template = `
- + Cancel
@@ -40,6 +42,7 @@ export class SaveDashboardAsModalCtrl { clone: any; folderId: any; dismiss: () => void; + isValidFolderSelection = true; /** @ngInject */ constructor(private dashboardSrv) { @@ -79,6 +82,14 @@ export class SaveDashboardAsModalCtrl { onFolderChange(folder) { this.folderId = folder.id; } + + onEnterFolderCreation() { + this.isValidFolderSelection = false; + } + + onExitFolderCreation() { + this.isValidFolderSelection = true; + } } export function saveDashboardAsDirective() { diff --git a/public/app/features/dashboard/specs/dashboard_migration.jest.ts b/public/app/features/dashboard/specs/dashboard_migration.jest.ts index 931527ec732..07a29d58e65 100644 --- a/public/app/features/dashboard/specs/dashboard_migration.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_migration.jest.ts @@ -374,7 +374,7 @@ describe('DashboardModel', function() { it('should assign id', function() { model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])]; - model.rows[0].panels[0] = { }; + model.rows[0].panels[0] = {}; let dashboard = new DashboardModel(model); expect(dashboard.panels[0].id).toBe(1); diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index d8757f49be6..429408ed803 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -241,31 +241,10 @@ export class PanelCtrl { }); } - removePanel(ask: boolean) { - // confirm deletion - if (ask !== false) { - var text2, confirmText; - - if (this.panel.alert) { - text2 = 'Panel includes an alert rule, removing panel will also remove alert rule'; - confirmText = 'YES'; - } - - appEvents.emit('confirm-modal', { - title: 'Remove Panel', - text: 'Are you sure you want to remove this panel?', - text2: text2, - icon: 'fa-trash', - confirmText: confirmText, - yesText: 'Remove', - onConfirm: () => { - this.removePanel(false); - }, - }); - return; - } - - this.dashboard.removePanel(this.panel); + removePanel() { + this.publishAppEvent('panel-remove', { + panelId: this.panel.id, + }); } editPanelJson() { diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index 2c7698db08e..242d2e7da3e 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -18,7 +18,7 @@ export class SoloPanelCtrl { // if no uid, redirect to new route based on slug if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { - backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => { + backendSrv.getDashboardBySlug($routeParams.slug).then(res => { if (res) { const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); $location.path(url).replace(); diff --git a/public/app/features/templating/specs/template_srv.jest.ts b/public/app/features/templating/specs/template_srv.jest.ts index 577669a41dd..37e2f5e4fe5 100644 --- a/public/app/features/templating/specs/template_srv.jest.ts +++ b/public/app/features/templating/specs/template_srv.jest.ts @@ -31,12 +31,40 @@ describe('templateSrv', function() { expect(target).toBe('this.mupp.filters'); }); + it('should replace ${test} with scoped value', function() { + var target = _templateSrv.replace('this.${test}.filters', { + test: { value: 'mupp', text: 'asd' }, + }); + expect(target).toBe('this.mupp.filters'); + }); + + it('should replace ${test:glob} with scoped value', function() { + var target = _templateSrv.replace('this.${test:glob}.filters', { + test: { value: 'mupp', text: 'asd' }, + }); + expect(target).toBe('this.mupp.filters'); + }); + it('should replace $test with scoped text', function() { var target = _templateSrv.replaceWithText('this.$test.filters', { test: { value: 'mupp', text: 'asd' }, }); expect(target).toBe('this.asd.filters'); }); + + it('should replace ${test} with scoped text', function() { + var target = _templateSrv.replaceWithText('this.${test}.filters', { + test: { value: 'mupp', text: 'asd' }, + }); + expect(target).toBe('this.asd.filters'); + }); + + it('should replace ${test:glob} with scoped text', function() { + var target = _templateSrv.replaceWithText('this.${test:glob}.filters', { + test: { value: 'mupp', text: 'asd' }, + }); + expect(target).toBe('this.asd.filters'); + }); }); describe('getAdhocFilters', function() { @@ -79,18 +107,34 @@ describe('templateSrv', function() { ]); }); + it('should replace $test with globbed value', function() { var target = _templateSrv.replace('this.$test.filters', {}, 'glob'); expect(target).toBe('this.{value1,value2}.filters'); }); + it('should replace ${test} with globbed value', function() { + var target = _templateSrv.replace('this.${test}.filters', {}, 'glob'); + expect(target).toBe('this.{value1,value2}.filters'); + }); + + it('should replace ${test:glob} with globbed value', function() { + var target = _templateSrv.replace('this.${test:glob}.filters', {}); + expect(target).toBe('this.{value1,value2}.filters'); + }); + it('should replace $test with piped value', function() { var target = _templateSrv.replace('this=$test', {}, 'pipe'); expect(target).toBe('this=value1|value2'); }); - it('should replace $test with piped value', function() { - var target = _templateSrv.replace('this=$test', {}, 'pipe'); + it('should replace ${test} with piped value', function() { + var target = _templateSrv.replace('this=${test}', {}, 'pipe'); + expect(target).toBe('this=value1|value2'); + }); + + it('should replace ${test:pipe} with piped value', function() { + var target = _templateSrv.replace('this=${test:pipe}', {}); expect(target).toBe('this=value1|value2'); }); }); @@ -111,6 +155,16 @@ describe('templateSrv', function() { var target = _templateSrv.replace('this.$test.filters', {}, 'glob'); expect(target).toBe('this.{value1,value2}.filters'); }); + + it('should replace ${test} with formatted all value', function() { + var target = _templateSrv.replace('this.${test}.filters', {}, 'glob'); + expect(target).toBe('this.{value1,value2}.filters'); + }); + + it('should replace ${test:glob} with formatted all value', function() { + var target = _templateSrv.replace('this.${test:glob}.filters', {}); + expect(target).toBe('this.{value1,value2}.filters'); + }); }); describe('variable with all option and custom value', function() { @@ -131,6 +185,16 @@ describe('templateSrv', function() { expect(target).toBe('this.*.filters'); }); + it('should replace ${test} with formatted all value', function() { + var target = _templateSrv.replace('this.${test}.filters', {}, 'glob'); + expect(target).toBe('this.*.filters'); + }); + + it('should replace ${test:glob} with formatted all value', function() { + var target = _templateSrv.replace('this.${test:glob}.filters', {}); + expect(target).toBe('this.*.filters'); + }); + it('should not escape custom all value', function() { var target = _templateSrv.replace('this.$test', {}, 'regex'); expect(target).toBe('this.*'); @@ -143,6 +207,18 @@ describe('templateSrv', function() { var target = _templateSrv.replace('this:$test', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); + + it('should properly escape ${test} with lucene escape sequences', function() { + initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + var target = _templateSrv.replace('this:${test}', {}, 'lucene'); + expect(target).toBe('this:value\\/4'); + }); + + it('should properly escape ${test:lucene} with lucene escape sequences', function() { + initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + var target = _templateSrv.replace('this:${test:lucene}', {}); + expect(target).toBe('this:value\\/4'); + }); }); describe('format variable to string values', function() { diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index fa5cabd847a..40f119ea10b 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -8,7 +8,13 @@ function luceneEscape(value) { export class TemplateSrv { variables: any[]; - private regex = /\$(\w+)|\[\[([\s\S]+?)\]\]/g; + /* + * This regex matches 3 types of variable reference with an optional format specifier + * \$(\w+) $var1 + * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} + */ + private regex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g; private index = {}; private grafanaVariables = {}; private builtIns = {}; @@ -89,6 +95,9 @@ export class TemplateSrv { } var escapedValues = _.map(value, kbn.regexEscape); + if (escapedValues.length === 1) { + return escapedValues[0]; + } return '(' + escapedValues.join('|') + ')'; } case 'lucene': { @@ -140,8 +149,8 @@ export class TemplateSrv { str = _.escape(str); this.regex.lastIndex = 0; - return str.replace(this.regex, (match, g1, g2) => { - if (this.index[g1 || g2] || this.builtIns[g1 || g2]) { + return str.replace(this.regex, (match, var1, var2, fmt2, var3) => { + if (this.index[var1 || var2 || var3] || this.builtIns[var1 || var2 || var3]) { return '' + match + ''; } return match; @@ -167,11 +176,11 @@ export class TemplateSrv { var variable, systemValue, value; this.regex.lastIndex = 0; - return target.replace(this.regex, (match, g1, g2) => { - variable = this.index[g1 || g2]; - + return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => { + variable = this.index[var1 || var2 || var3]; + format = fmt2 || fmt3 || format; if (scopedVars) { - value = scopedVars[g1 || g2]; + value = scopedVars[var1 || var2 || var3]; if (value) { return this.formatValue(value.value, format, variable); } @@ -212,15 +221,15 @@ export class TemplateSrv { var variable; this.regex.lastIndex = 0; - return target.replace(this.regex, (match, g1, g2) => { + return target.replace(this.regex, (match, var1, var2, fmt2, var3) => { if (scopedVars) { - var option = scopedVars[g1 || g2]; + var option = scopedVars[var1 || var2 || var3]; if (option) { return option.text; } } - variable = this.index[g1 || g2]; + variable = this.index[var1 || var2 || var3]; if (!variable) { return match; } diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 54c59c04429..8680924977f 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -59,7 +59,7 @@ Sign in with {{oauth.generic_oauth.name}} -