ソースを参照

Merge remote-tracking branch 'upstream/master' into prometheus-heatmap

Alexander Zobnin 7 年 前
コミット
fa8403750b
87 ファイル変更3535 行追加943 行削除
  1. 20 0
      CHANGELOG.md
  2. 15 12
      ROADMAP.md
  3. 1 4
      conf/defaults.ini
  4. 0 3
      conf/sample.ini
  5. 25 0
      docker/blocks/prometheus2/docker-compose.yaml
  6. 1 1
      docs/sources/http_api/admin.md
  7. 1 1
      docs/sources/http_api/data_source.md
  8. 4 7
      docs/sources/installation/configuration.md
  9. 3 3
      docs/sources/installation/debian.md
  10. 2 2
      docs/sources/installation/rpm.md
  11. 3 7
      docs/sources/installation/windows.md
  12. 3 3
      package.json
  13. 2 2
      packaging/publish/publish_testing.sh
  14. 22 4
      pkg/api/api.go
  15. 23 6
      pkg/api/dashboard.go
  16. 0 214
      pkg/api/dashboard_acl_test.go
  17. 15 10
      pkg/api/dashboard_permission.go
  18. 210 0
      pkg/api/dashboard_permission_test.go
  19. 35 7
      pkg/api/dashboard_snapshot.go
  20. 97 0
      pkg/api/dashboard_snapshot_test.go
  21. 75 2
      pkg/api/dashboard_test.go
  22. 25 0
      pkg/api/dtos/folder.go
  23. 147 0
      pkg/api/folder.go
  24. 108 0
      pkg/api/folder_permission.go
  25. 242 0
      pkg/api/folder_permission_test.go
  26. 254 0
      pkg/api/folder_test.go
  27. 25 1
      pkg/models/dashboard_acl.go
  28. 7 4
      pkg/models/dashboard_snapshot.go
  29. 1 0
      pkg/models/dashboard_version.go
  30. 3 7
      pkg/models/dashboards.go
  31. 91 0
      pkg/models/folders.go
  32. 2 0
      pkg/services/alerting/notifiers/teams.go
  33. 12 2
      pkg/services/cleanup/cleanup.go
  34. 16 11
      pkg/services/dashboards/dashboard_service.go
  35. 2 51
      pkg/services/dashboards/dashboard_service_test.go
  36. 245 0
      pkg/services/dashboards/folder_service.go
  37. 191 0
      pkg/services/dashboards/folder_service_test.go
  38. 107 5
      pkg/services/guardian/guardian.go
  39. 711 0
      pkg/services/guardian/guardian_test.go
  40. 16 4
      pkg/services/sqlstore/dashboard.go
  41. 23 75
      pkg/services/sqlstore/dashboard_service_integration_test.go
  42. 26 12
      pkg/services/sqlstore/dashboard_snapshot.go
  43. 136 2
      pkg/services/sqlstore/dashboard_snapshot_test.go
  44. 25 0
      pkg/services/sqlstore/dashboard_test.go
  45. 1 3
      pkg/services/sqlstore/dashboard_version.go
  46. 0 2
      pkg/setting/setting.go
  47. 6 5
      pkg/social/github_oauth.go
  48. 1 1
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  49. 7 10
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  50. 17 14
      public/app/containers/ManageDashboards/FolderSettings.tsx
  51. 3 3
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  52. 0 8
      public/app/core/components/Permissions/AddPermissions.tsx
  53. 0 4
      public/app/core/components/help/help.ts
  54. 5 40
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  55. 27 37
      public/app/core/services/backend_srv.ts
  56. 5 26
      public/app/core/services/keybindingSrv.ts
  57. 21 0
      public/app/core/utils/kbn.ts
  58. 2 2
      public/app/features/dashboard/create_folder_ctrl.ts
  59. 39 0
      public/app/features/dashboard/dashboard_ctrl.ts
  60. 28 0
      public/app/features/dashboard/dashboard_model.ts
  61. 1 1
      public/app/features/dashboard/dashboard_srv.ts
  62. 6 4
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  63. 1 1
      public/app/features/dashboard/folder_dashboards_ctrl.ts
  64. 6 6
      public/app/features/dashboard/folder_page_loader.ts
  65. 3 3
      public/app/features/dashboard/folder_picker/folder_picker.ts
  66. 10 18
      public/app/features/dashboard/folder_settings_ctrl.ts
  67. 12 1
      public/app/features/dashboard/save_as_modal.ts
  68. 1 1
      public/app/features/dashboard/specs/dashboard_migration.jest.ts
  69. 4 25
      public/app/features/panel/panel_ctrl.ts
  70. 1 1
      public/app/features/panel/solo_panel_ctrl.ts
  71. 78 2
      public/app/features/templating/specs/template_srv.jest.ts
  72. 19 10
      public/app/features/templating/template_srv.ts
  73. 1 1
      public/app/partials/login.html
  74. 1 0
      public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html
  75. 4 0
      public/app/plugins/datasource/graphite/query_ctrl.ts
  76. 1 1
      public/app/routes/dashboard_loaders.ts
  77. 14 12
      public/app/stores/FolderStore/FolderStore.ts
  78. 12 67
      public/app/stores/PermissionsStore/PermissionsStore.jest.ts
  79. 19 34
      public/app/stores/PermissionsStore/PermissionsStore.ts
  80. 93 129
      public/sass/base/_fonts.scss
  81. 1 1
      public/sass/components/_dashboard_grid.scss
  82. 1 1
      public/sass/components/_scrollbar.scss
  83. 3 0
      public/sass/components/_view_states.scss
  84. 1 0
      public/test/mocks/common.ts
  85. 78 0
      tests/api/folder.test.ts
  86. 17 1
      tests/api/setup.ts
  87. 13 6
      yarn.lock

+ 20 - 0
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

+ 15 - 12
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) 

+ 1 - 4
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

+ 0 - 3
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

+ 25 - 0
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"

+ 1 - 1
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"
   },

+ 1 - 1
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**:
 

+ 4 - 7
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.

+ 3 - 3
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

+ 2 - 2
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`.

+ 3 - 7
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.

+ 3 - 3
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"
   }
 }

+ 2 - 2
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
 

+ 22 - 4
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))
 				})
 			})
 		})

+ 23 - 6
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.

+ 0 - 214
pkg/api/dashboard_acl_test.go

@@ -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)
-	})
-}

+ 15 - 10
pkg/api/dashboard_acl.go → 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")
 }

+ 210 - 0
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)
+	})
+}

+ 35 - 7
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)

+ 97 - 0
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")
+				})
+			})
+		})
+	})
+}

+ 75 - 2
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)

+ 25 - 0
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"`
+}

+ 147 - 0
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)
+}

+ 108 - 0
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")
+}

+ 242 - 0
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)
+	})
+}

+ 254 - 0
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
+	}
+}

+ 25 - 1
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
 //

+ 7 - 4
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
 }

+ 1 - 0
pkg/models/dashboard_version.go

@@ -75,4 +75,5 @@ type GetDashboardVersionsQuery struct {
 //
 
 type DeleteExpiredVersionsCommand struct {
+	DeletedRows int64
 }

+ 3 - 7
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

+ 91 - 0
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
+}

+ 2 - 0
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{}{

+ 12 - 2
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() {

+ 16 - 11
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
 	}

+ 2 - 51
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
-}

+ 245 - 0
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
+}

+ 191 - 0
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)
+				}
+			}
+		})
+	})
+}

+ 107 - 5
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 {
-		acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission})
+		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)
+	}
+
+	existingPermissions, err := g.GetAcl()
+	if err != nil {
+		return false, err
+	}
+
+	// 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
+	}
+}

+ 711 - 0
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)
+}

+ 16 - 4
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
 		}
 	}
 

+ 23 - 75
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,

+ 26 - 12
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

+ 136 - 2
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
+}

+ 25 - 0
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() {

+ 1 - 3
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

+ 0 - 2
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")

+ 6 - 5
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
 	}
 

+ 1 - 1
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -26,7 +26,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
   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');
     });
   }

+ 7 - 10
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,
       })
     );
 

+ 17 - 14
public/app/containers/ManageDashboards/FolderSettings.tsx

@@ -10,7 +10,6 @@ import appEvents from 'app/core/app_events';
 @observer
 export class FolderSettings extends React.Component<IContainerProps, any> {
   formSnapshot: any;
-  dashboard: any;
 
   constructor(props) {
     super(props);
@@ -22,9 +21,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
 
     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<IContainerProps, any> {
     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<IContainerProps, any> {
       .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<IContainerProps, any> {
       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<IContainerProps, any> {
     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<IContainerProps, any> {
         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() {

+ 3 - 3
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');
     });
   });
 

+ 0 - 8
public/app/core/components/Permissions/AddPermissions.tsx

@@ -135,14 +135,6 @@ class AddPermissions extends Component<IProps, any> {
             </div>
           </div>
         </form>
-        {permissions.error ? (
-          <div className="gf-form width-17">
-            <span ng-if="ctrl.error" className="text-error p-l-1">
-              <i className="fa fa-warning" />
-              {permissions.error}
-            </span>
-          </div>
-        ) : null}
       </div>
     );
   }

+ 0 - 4
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' },
         {

+ 5 - 40
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();
     });
   }

+ 27 - 37
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();
+  deleteFolder(uid: string, showSuccessAlert) {
+    return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true });
+  }
 
-    this.getDashboardByUid(uid).then(fullDash => {
-      this.delete(`/api/dashboards/uid/${uid}`)
-        .then(() => {
-          deferred.resolve(fullDash);
-        })
-        .catch(err => {
-          deferred.reject(err);
-        });
+  deleteDashboard(uid, showSuccessAlert) {
+    return this.request({
+      method: 'DELETE',
+      url: `/api/dashboards/uid/${uid}`,
+      showSuccessAlert: showSuccessAlert === true,
     });
-
-    return deferred.promise;
   }
 
-  deleteDashboards(dashboardUids) {
+  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, []);

+ 5 - 26
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 => {

+ 21 - 0
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: [

+ 2 - 2
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));
     });
   }
 

+ 39 - 0
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);
   }
 }

+ 28 - 0
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;
   }

+ 1 - 1
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)

+ 6 - 4
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}
     </ReactGridLayout>
   );
@@ -178,7 +179,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
       panelElements.push(
         <div key={panel.id.toString()} className={panelClasses}>
           <DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
-        </div>,
+        </div>
       );
     }
 
@@ -196,7 +197,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
         onWidthChange={this.onWidthChange}
         onDragStop={this.onDragStop}
         onResize={this.onResize}
-        onResizeStop={this.onResizeStop}>
+        onResizeStop={this.onResizeStop}
+      >
         {this.renderPanels()}
       </SizedReactLayoutGrid>
     );

+ 1 - 1
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();

+ 6 - 6
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;
     });
   }
 }

+ 3 - 3
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);
     });

+ 10 - 18
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.']);
-    }
   }
 }

+ 12 - 1
public/app/features/dashboard/save_as_modal.ts

@@ -22,6 +22,8 @@ const template = `
       <div class="gf-form">
         <folder-picker initial-folder-id="ctrl.folderId"
                        on-change="ctrl.onFolderChange($folder)"
+                       enter-folder-creation="ctrl.onEnterFolderCreation()"
+                       exit-folder-creation="ctrl.onExitFolderCreation()"
                        enable-create-new="true"
                        label-class="width-7">
         </folder-picker>
@@ -29,7 +31,7 @@ const template = `
 		</div>
 
 		<div class="gf-form-button-row text-center">
-			<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
+			<button type="submit" class="btn btn-success" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
 			<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
 		</div>
 	</form>
@@ -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() {

+ 1 - 1
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);

+ 4 - 25
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() {

+ 1 - 1
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();

+ 78 - 2
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() {

+ 19 - 10
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 '<span class="template-variable">' + match + '</span>';
       }
       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;
       }

+ 1 - 1
public/app/partials/login.html

@@ -59,7 +59,7 @@
           Sign in with {{oauth.generic_oauth.name}}
         </a>
       </div>
-      <div class="login-signup-box">
+      <div class="login-signup-box" ng-show="!disableUserSignUp">
         <div class="login-signup-title p-r-1">
           New to Grafana?
         </div>

+ 1 - 0
public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html

@@ -101,6 +101,7 @@
 												get-options="getSizeOptions()"
 												on-change="onChangeInternal()"
 												label-mode="true"
+                        allow-custom="true"
 												css-class="width-12">
 			</gf-form-dropdown>
 		</div>

+ 4 - 0
public/app/plugins/datasource/graphite/query_ctrl.ts

@@ -348,6 +348,10 @@ export class GraphiteQueryCtrl extends QueryCtrl {
     let tagKey = tag.key;
     return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then(values => {
       let altValues = _.map(values, 'text');
+      // Add template variables as additional values
+      _.eachRight(this.templateSrv.variables, variable => {
+        altValues.push('${' + variable.name + ':regex}');
+      });
       return mapToDropdownOptions(altValues);
     });
   }

+ 1 - 1
public/app/routes/dashboard_loaders.ts

@@ -21,7 +21,7 @@ export class LoadDashboardCtrl {
 
     // 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) {
           $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
         }

+ 14 - 12
public/app/stores/FolderStore/FolderStore.ts

@@ -2,11 +2,12 @@ import { types, getEnv, flow } from 'mobx-state-tree';
 
 export const Folder = types.model('Folder', {
   id: types.identifier(types.number),
+  uid: types.string,
   title: types.string,
   url: types.string,
   canSave: types.boolean,
-  uid: types.string,
   hasChanged: types.boolean,
+  version: types.number,
 });
 
 export const FolderStore = types
@@ -21,15 +22,15 @@ export const FolderStore = types
       }
 
       const backendSrv = getEnv(self).backendSrv;
-      const res = yield backendSrv.getDashboardByUid(uid);
-
+      const res = yield backendSrv.getFolderByUid(uid);
       self.folder = Folder.create({
-        id: res.dashboard.id,
-        title: res.dashboard.title,
-        url: res.meta.url,
-        uid: res.dashboard.uid,
-        canSave: res.meta.canSave,
+        id: res.id,
+        uid: res.uid,
+        title: res.title,
+        url: res.url,
+        canSave: res.canSave,
         hasChanged: false,
+        version: res.version,
       });
 
       return res;
@@ -40,12 +41,13 @@ export const FolderStore = types
       self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0;
     },
 
-    saveFolder: flow(function* saveFolder(dashboard: any, options: any) {
+    saveFolder: flow(function* saveFolder(options: any) {
       const backendSrv = getEnv(self).backendSrv;
-      dashboard.title = self.folder.title.trim();
+      self.folder.title = self.folder.title.trim();
 
-      const res = yield backendSrv.saveFolder(dashboard, options);
+      const res = yield backendSrv.updateFolder(self.folder, options);
       self.folder.url = res.url;
+      self.folder.version = res.version;
 
       return `${self.folder.url}/settings`;
     }),
@@ -53,6 +55,6 @@ export const FolderStore = types
     deleteFolder: flow(function* deleteFolder() {
       const backendSrv = getEnv(self).backendSrv;
 
-      return backendSrv.deleteDashboard(self.folder.uid);
+      return backendSrv.deleteFolder(self.folder.uid);
     }),
   }));

+ 12 - 67
public/app/stores/PermissionsStore/PermissionsStore.jest.ts

@@ -1,10 +1,10 @@
-import { PermissionsStore, aclTypeValues } from './PermissionsStore';
+import { PermissionsStore } from './PermissionsStore';
 import { backendSrv } from 'test/mocks/common';
 
 describe('PermissionsStore', () => {
   let store;
 
-  beforeEach(() => {
+  beforeEach(async () => {
     backendSrv.get.mockReturnValue(
       Promise.resolve([
         { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
@@ -20,7 +20,7 @@ describe('PermissionsStore', () => {
       ])
     );
 
-    backendSrv.post = jest.fn();
+    backendSrv.post = jest.fn(() => Promise.resolve({}));
 
     store = PermissionsStore.create(
       {
@@ -32,84 +32,33 @@ describe('PermissionsStore', () => {
       }
     );
 
-    return store.load(1, false, false);
+    await store.load(1, false, false);
   });
 
-  it('should save update on permission change', () => {
+  it('should save update on permission change', async () => {
     expect(store.items[0].permission).toBe(1);
     expect(store.items[0].permissionName).toBe('View');
 
-    store.updatePermissionOnIndex(0, 2, 'Edit');
+    await store.updatePermissionOnIndex(0, 2, 'Edit');
 
     expect(store.items[0].permission).toBe(2);
     expect(store.items[0].permissionName).toBe('Edit');
     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');
   });
 
-  it('should save removed permissions automatically', () => {
+  it('should save removed permissions automatically', async () => {
     expect(store.items.length).toBe(3);
 
-    store.removeStoreItem(2);
+    await store.removeStoreItem(2);
 
     expect(store.items.length).toBe(2);
     expect(backendSrv.post.mock.calls.length).toBe(1);
-    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-  });
-
-  describe('when duplicate team permissions are added', () => {
-    beforeEach(() => {
-      const newItem = {
-        teamId: 10,
-        team: 'tester-team',
-        permission: 1,
-        dashboardId: 1,
-      };
-      store.resetNewType();
-      store.newItem.setTeam(newItem.teamId, newItem.team);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-
-      store.newItem.setTeam(newItem.teamId, newItem.team);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-    });
-
-    it('should return a validation error', () => {
-      expect(store.items.length).toBe(4);
-      expect(store.error).toBe('This permission exists already.');
-      expect(backendSrv.post.mock.calls.length).toBe(1);
-    });
-  });
-
-  describe('when duplicate user permissions are added', () => {
-    beforeEach(() => {
-      expect(store.items.length).toBe(3);
-      const newItem = {
-        userId: 10,
-        userLogin: 'tester1',
-        permission: 1,
-        dashboardId: 1,
-      };
-      store.setNewType(aclTypeValues.USER.value);
-      store.newItem.setUser(newItem.userId, newItem.userLogin);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-      store.setNewType(aclTypeValues.USER.value);
-      store.newItem.setUser(newItem.userId, newItem.userLogin);
-      store.newItem.setPermission(newItem.permission);
-      store.addStoreItem();
-    });
-
-    it('should return a validation error', () => {
-      expect(store.items.length).toBe(4);
-      expect(store.error).toBe('This permission exists already.');
-      expect(backendSrv.post.mock.calls.length).toBe(1);
-    });
+    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
   });
 
   describe('when one inherited and one not inherited team permission are added', () => {
-    beforeEach(() => {
+    beforeEach(async () => {
       const overridingItemForChildDashboard = {
         team: 'MyTestTeam',
         dashboardId: 1,
@@ -120,11 +69,7 @@ describe('PermissionsStore', () => {
       store.resetNewType();
       store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team);
       store.newItem.setPermission(overridingItemForChildDashboard.permission);
-      store.addStoreItem();
-    });
-
-    it('should allowing overriding the inherited permission and not throw a validation error', () => {
-      expect(store.error).toBe(null);
+      await store.addStoreItem();
     });
 
     it('should add new overriding permission', () => {

+ 19 - 34
public/app/stores/PermissionsStore/PermissionsStore.ts

@@ -1,8 +1,6 @@
 import { types, getEnv, flow } from 'mobx-state-tree';
 import { PermissionsStoreItem } from './PermissionsStoreItem';
 
-const duplicateError = 'This permission exists already.';
-
 export const permissionOptions = [
   { value: 1, label: 'View', description: 'Can view dashboards.' },
   { value: 2, label: 'Edit', description: 'Can add, edit and delete dashboards.' },
@@ -75,7 +73,6 @@ export const PermissionsStore = types
     isFolder: types.maybe(types.boolean),
     dashboardId: types.maybe(types.number),
     items: types.optional(types.array(PermissionsStoreItem), []),
-    error: types.maybe(types.string),
     originalItems: types.optional(types.array(PermissionsStoreItem), []),
     newType: types.optional(types.string, defaultNewType),
     newItem: types.maybe(NewPermissionsItem),
@@ -88,7 +85,6 @@ export const PermissionsStore = types
         return isDuplicate(it, item);
       });
       if (dupe) {
-        self.error = duplicateError;
         return false;
       }
 
@@ -96,8 +92,7 @@ export const PermissionsStore = types
     },
   }))
   .actions(self => {
-    const resetNewType = () => {
-      self.error = null;
+    const resetNewTypeInternal = () => {
       self.newItem = NewPermissionsItem.create();
     };
 
@@ -110,16 +105,14 @@ export const PermissionsStore = types
         self.dashboardId = dashboardId;
         self.items.clear();
 
-        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
+        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/permissions`);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         self.items = items;
         self.originalItems = items;
         self.fetching = false;
-        self.error = null;
       }),
 
       addStoreItem: flow(function* addStoreItem() {
-        self.error = null;
         let item = {
           type: self.newItem.type,
           permission: self.newItem.permission,
@@ -147,19 +140,21 @@ export const PermissionsStore = types
             throw Error('Unknown type: ' + self.newItem.type);
         }
 
-        if (!self.isValid(item)) {
-          return undefined;
-        }
+        const updatedItems = self.items.peek();
+        const newItem = prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot);
+        updatedItems.push(newItem);
 
-        self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
-        resetNewType();
-        return updateItems(self);
+        try {
+          yield updateItems(self, updatedItems);
+          self.items.push(newItem);
+          resetNewTypeInternal();
+        } catch {}
+        yield Promise.resolve();
       }),
 
       removeStoreItem: flow(function* removeStoreItem(idx: number) {
-        self.error = null;
         self.items.splice(idx, 1);
-        return updateItems(self);
+        yield updateItems(self, self.items.peek());
       }),
 
       updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
@@ -167,9 +162,8 @@ export const PermissionsStore = types
         permission: number,
         permissionName: string
       ) {
-        self.error = null;
         self.items[idx].updatePermission(permission, permissionName);
-        return updateItems(self);
+        yield updateItems(self, self.items.peek());
       }),
 
       setNewType(newType: string) {
@@ -177,7 +171,7 @@ export const PermissionsStore = types
       },
 
       resetNewType() {
-        resetNewType();
+        resetNewTypeInternal();
       },
 
       toggleAddPermissions() {
@@ -190,12 +184,10 @@ export const PermissionsStore = types
     };
   });
 
-const updateItems = self => {
-  self.error = null;
-
+const updateItems = (self, items) => {
   const backendSrv = getEnv(self).backendSrv;
   const updated = [];
-  for (let item of self.items) {
+  for (let item of items) {
     if (item.inherited) {
       continue;
     }
@@ -208,16 +200,9 @@ const updateItems = self => {
     });
   }
 
-  let res;
-  try {
-    res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/acl`, {
-      items: updated,
-    });
-  } catch (error) {
-    self.error = error;
-  }
-
-  return res;
+  return backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, {
+    items: updated,
+  });
 };
 
 const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {

+ 93 - 129
public/sass/base/_fonts.scss

@@ -1,290 +1,254 @@
-@import "font_awesome";
-@import "grafana_icons";
+@import 'font_awesome';
+@import 'grafana_icons';
 
 /* cyrillic-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0370-03ff;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 /* latin */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto'), local('Roboto-Regular'), url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 /* cyrillic-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0370-03ff;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 /* latin */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 /* cyrillic-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0370-03ff;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 /* latin */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 /* cyrillic-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2) format('woff2');
   unicode-range: U+0370-03ff;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 /* latin */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }

+ 1 - 1
public/sass/components/_dashboard_grid.scss

@@ -1,4 +1,4 @@
-@import '~react-grid-layout/css/styles.css';
+@import '~react-grid-layout-grafana/css/styles.css';
 @import '~react-resizable/css/styles.css';
 
 .panel-in-fullscreen {

+ 1 - 1
public/sass/components/_scrollbar.scss

@@ -75,7 +75,7 @@
   border-radius: 6px;
   width: 6px;
   /* there must be 'right' for ps__thumb-y */
-  right: 2px;
+  right: 0px;
   /* please don't change 'position' */
   position: absolute;
 }

+ 3 - 0
public/sass/components/_view_states.scss

@@ -3,6 +3,9 @@
   .navbar {
     display: none;
   }
+  .scroll-canvas--dashboard {
+    height: 100%;
+  }
 }
 
 .playlist-active,

+ 1 - 0
public/test/mocks/common.ts

@@ -2,6 +2,7 @@ export const backendSrv = {
   get: jest.fn(),
   getDashboard: jest.fn(),
   getDashboardByUid: jest.fn(),
+  getFolderByUid: jest.fn(),
   post: jest.fn(),
 };
 

+ 78 - 0
tests/api/folder.test.ts

@@ -0,0 +1,78 @@
+import client from './client';
+import * as setup from './setup';
+
+describe('/api/folders', () => {
+  let state: any = {};
+
+  beforeAll(async () => {
+    state = await setup.ensureState({
+      orgName: 'api-test-org',
+      users: [
+        { user: setup.admin, role: 'Admin' },
+        { user: setup.editor, role: 'Editor' },
+        { user: setup.viewer, role: 'Viewer' },
+      ],
+      admin: setup.admin,
+      folders: [
+        {
+          title: 'Folder 1',
+          uid: 'f-01',
+        },
+        {
+          title: 'Folder 2',
+          uid: 'f-02',
+        },
+        {
+          title: 'Folder 3',
+          uid: 'f-03',
+        },
+      ],
+    });
+  });
+
+  describe('With admin user', () => {
+    it('can delete folder', async () => {
+      let rsp = await client.callAs(setup.admin).delete(`/api/folders/f-01`);
+      expect(rsp.data.title).toBe('Folder 1');
+    });
+
+    it('can update folder', async () => {
+      let rsp = await client.callAs(setup.admin).put(`/api/folders/f-02`, {
+        uid: 'f-02',
+        title: 'Folder 2 upd',
+        overwrite: true,
+      });
+      expect(rsp.data.title).toBe('Folder 2 upd');
+    });
+
+    it('can update folder uid', async () => {
+      let rsp = await client.callAs(setup.admin).put(`/api/folders/f-03`, {
+        uid: 'f-03-upd',
+        title: 'Folder 3 upd',
+        overwrite: true,
+      });
+      expect(rsp.data.uid).toBe('f-03-upd');
+      expect(rsp.data.title).toBe('Folder 3 upd');
+    });
+  });
+
+  describe('With viewer user', () => {
+    it('Cannot delete folder', async () => {
+      let rsp = await setup.expectError(() => {
+        return client.callAs(setup.viewer).delete(`/api/folders/f-02`);
+      });
+      expect(rsp.response.status).toBe(403);
+    });
+
+    it('Cannot update folder', async () => {
+      let rsp = await setup.expectError(() => {
+        return client.callAs(setup.viewer).put(`/api/folders/f-02`, {
+          uid: 'f-02',
+          title: 'Folder 2 upd',
+          overwrite: true,
+        });
+      });
+      expect(rsp.response.status).toBe(403);
+    });
+  });
+});

+ 17 - 1
tests/api/setup.ts

@@ -90,6 +90,18 @@ export async function createDashboard(user, dashboard) {
   return dashboard;
 }
 
+export async function createFolder(user, folder) {
+  const rsp = await client.callAs(user).post(`/api/folders`, {
+    uid: folder.uid,
+    title: folder.title,
+    overwrite: true,
+  });
+  folder.id = rsp.id;
+  folder.url = rsp.url;
+
+  return folder;
+}
+
 export async function ensureState(state) {
   const org = await getOrg(state.orgName);
 
@@ -99,9 +111,13 @@ export async function ensureState(state) {
     await setUsingOrg(user, org);
   }
 
-  for (let dashboard of state.dashboards) {
+  for (let dashboard of state.dashboards || []) {
     await createDashboard(state.admin, dashboard);
   }
 
+  for (let folder of state.folders || []) {
+    await createFolder(state.admin, folder);
+  }
+
   return state;
 }

+ 13 - 6
yarn.lock

@@ -8275,16 +8275,23 @@ react-dom@^16.2.0:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
-"react-draggable@^2.2.6 || ^3.0.3", react-draggable@^3.0.3:
+"react-draggable@^2.2.6 || ^3.0.3":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.3.tgz#a6f9b3a7171981b76dadecf238316925cb9eacf4"
   dependencies:
     classnames "^2.2.5"
     prop-types "^15.5.10"
 
-react-grid-layout@^0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-0.16.2.tgz#ef09b0b6db4a9635799663658277ee2d26fa2994"
+react-draggable@^3.0.3:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d"
+  dependencies:
+    classnames "^2.2.5"
+    prop-types "^15.6.0"
+
+react-grid-layout-grafana@0.16.0:
+  version "0.16.0"
+  resolved "https://registry.yarnpkg.com/react-grid-layout-grafana/-/react-grid-layout-grafana-0.16.0.tgz#12242153fcd0bb80a26af8e41694bc2fde788b3a"
   dependencies:
     classnames "2.x"
     lodash.isequal "^4.0.0"
@@ -9773,9 +9780,9 @@ test-exclude@^4.1.1:
     read-pkg-up "^1.0.1"
     require-main-filename "^1.0.1"
 
-"tether-drop@https://github.com/torkelo/drop":
+"tether-drop@https://github.com/torkelo/drop/tarball/master":
   version "1.5.0"
-  resolved "https://github.com/torkelo/drop#fc83ca88db0076fbf6359cbe1743a9ef0f1ee6e1"
+  resolved "https://github.com/torkelo/drop/tarball/master#6a3eb15b882b416f06e1e7ae04c7e57d08418020"
   dependencies:
     tether "^1.1.0"