Browse Source

Merge branch '7883_new_url_structure' into 10630_folder_api

Marcus Efraimsson 8 years ago
parent
commit
b07ac3c054
100 changed files with 4037 additions and 1111 deletions
  1. 2 2
      .gitignore
  2. 16 23
      CHANGELOG.md
  3. 7 1
      Gopkg.lock
  4. 4 0
      Gopkg.toml
  5. 1 1
      circle.yml
  6. 3 0
      conf/defaults.ini
  7. 3 0
      conf/sample.ini
  8. 1 1
      docker/blocks/prometheus/Dockerfile
  9. 1 0
      docs/Dockerfile
  10. 76 0
      docs/sources/administration/permissions.md
  11. 1 1
      docs/sources/administration/provisioning.md
  12. 1 1
      docs/sources/features/index.md
  13. 1 0
      docs/sources/guides/basic_concepts.md
  14. 2 1
      docs/sources/guides/getting_started.md
  15. 1 1
      docs/sources/guides/index.md
  16. 120 0
      docs/sources/guides/whats-new-in-v5.md
  17. 1 5
      docs/sources/index.md
  18. 0 25
      docs/sources/installation/configuration.md
  19. 1 0
      docs/sources/installation/index.md
  20. 0 42
      docs/sources/reference/admin.md
  21. 1 1
      docs/sources/reference/sharing.md
  22. 1 1
      docs/sources/whatsnew/index.md
  23. 9 0
      docs/versions.json
  24. 5 0
      package.json
  25. 55 25
      pkg/api/alerting.go
  26. 97 0
      pkg/api/alerting_test.go
  27. 2 31
      pkg/api/annotations_test.go
  28. 13 3
      pkg/api/api.go
  29. 105 0
      pkg/api/common_test.go
  30. 56 9
      pkg/api/dashboard.go
  31. 14 22
      pkg/api/dashboard_acl_test.go
  32. 395 42
      pkg/api/dashboard_test.go
  33. 0 91
      pkg/api/datasources_test.go
  34. 1 0
      pkg/api/dtos/alerting.go
  35. 1 0
      pkg/api/dtos/dashboard.go
  36. 8 4
      pkg/api/folders.go
  37. 4 3
      pkg/api/login.go
  38. 12 7
      pkg/components/renderer/renderer.go
  39. 24 4
      pkg/log/log.go
  40. 39 0
      pkg/log/log_writer.go
  41. 116 0
      pkg/log/log_writer_test.go
  42. 21 32
      pkg/login/auth.go
  43. 214 0
      pkg/login/auth_test.go
  44. 48 0
      pkg/login/brute_force_login_protection.go
  45. 125 0
      pkg/login/brute_force_login_protection_test.go
  46. 35 0
      pkg/login/grafana_login.go
  47. 139 0
      pkg/login/grafana_login_test.go
  48. 21 0
      pkg/login/ldap_login.go
  49. 172 0
      pkg/login/ldap_login_test.go
  50. 0 0
      pkg/login/ldap_settings.go
  51. 46 0
      pkg/middleware/dashboard_redirect.go
  52. 56 0
      pkg/middleware/dashboard_redirect_test.go
  53. 14 0
      pkg/middleware/middleware_test.go
  54. 0 4
      pkg/models/alert.go
  55. 85 9
      pkg/models/dashboards.go
  56. 16 12
      pkg/models/folders.go
  57. 36 0
      pkg/models/login_attempt.go
  58. 9 4
      pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go
  59. 1 1
      pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go
  60. 25 22
      pkg/services/alerting/eval_context.go
  61. 2 2
      pkg/services/alerting/notifier.go
  62. 119 119
      pkg/services/alerting/ticker_test.go
  63. 16 0
      pkg/services/cleanup/cleanup.go
  64. 2 1
      pkg/services/search/models.go
  65. 0 7
      pkg/services/sqlstore/alert.go
  66. 190 30
      pkg/services/sqlstore/dashboard.go
  67. 69 46
      pkg/services/sqlstore/dashboard_acl.go
  68. 18 0
      pkg/services/sqlstore/dashboard_acl_test.go
  69. 349 0
      pkg/services/sqlstore/dashboard_folder_test.go
  70. 196 280
      pkg/services/sqlstore/dashboard_test.go
  71. 2 2
      pkg/services/sqlstore/dashboard_version_test.go
  72. 91 0
      pkg/services/sqlstore/login_attempt.go
  73. 125 0
      pkg/services/sqlstore/login_attempt_test.go
  74. 17 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  75. 23 0
      pkg/services/sqlstore/migrations/login_attempt_mig.go
  76. 1 0
      pkg/services/sqlstore/migrations/migrations.go
  77. 5 0
      pkg/services/sqlstore/migrator/dialect.go
  78. 4 0
      pkg/services/sqlstore/migrator/sqlite_dialect.go
  79. 1 0
      pkg/services/sqlstore/search_builder.go
  80. 1 1
      pkg/services/sqlstore/sqlutil/sqlutil.go
  81. 10 8
      pkg/setting/setting.go
  82. 15 0
      pkg/util/shortid_generator.go
  83. 2 1
      public/app/containers/AlertRuleList/AlertRuleList.jest.tsx
  84. 21 7
      public/app/containers/AlertRuleList/AlertRuleList.tsx
  85. 5 4
      public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap
  86. 22 3
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  87. 2 2
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  88. 4 2
      public/app/containers/ManageDashboards/FolderSettings.tsx
  89. 37 0
      public/app/core/components/Animations/SlideDown.tsx
  90. 90 0
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  91. 158 0
      public/app/core/components/Permissions/AddPermissions.tsx
  92. 27 6
      public/app/core/components/Permissions/DashboardPermissions.tsx
  93. 0 73
      public/app/core/components/Permissions/Permissions.jest.tsx
  94. 6 65
      public/app/core/components/Permissions/Permissions.tsx
  95. 12 8
      public/app/core/components/Permissions/PermissionsListItem.tsx
  96. 2 8
      public/app/core/components/Picker/DescriptionOption.tsx
  97. 19 0
      public/app/core/components/Picker/TeamPicker.jest.tsx
  98. 7 2
      public/app/core/components/Picker/TeamPicker.tsx
  99. 6 3
      public/app/core/components/Picker/UserPicker.tsx
  100. 98 0
      public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap

+ 2 - 2
.gitignore

@@ -10,8 +10,8 @@ awsconfig
 /public_gen
 /public_gen
 /public/vendor/npm
 /public/vendor/npm
 /tmp
 /tmp
-vendor/phantomjs/phantomjs
-vendor/phantomjs/phantomjs.exe
+tools/phantomjs/phantomjs
+tools/phantomjs/phantomjs.exe
 profile.out
 profile.out
 coverage.txt
 coverage.txt
 
 

+ 16 - 23
CHANGELOG.md

@@ -2,7 +2,7 @@
 
 
 Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5.
 Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5.
 
 
-### New Features
+### New Major Features
 - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611)
 - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611)
 - **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list.
 - **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list.
 - **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093).
 - **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093).
@@ -10,31 +10,22 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha
 - **UX**: Major update to page header and navigation
 - **UX**: Major update to page header and navigation
 - **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750)
 - **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750)
 
 
-## New Dashboard Grid
-
-The new grid engine is major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backwards compatible. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height.
-
-Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update.
-
-## New Features
-* **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM)
-
-## Minor
-* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc)
-* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda)
-
-# 4.7.0 (unreleased / v4.7.x branch)
-
 ## Breaking changes
 ## Breaking changes
 
 
-`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/).
-
+* **[dashboard.json]** have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/).
 Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`.
 Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`.
 From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages.
 From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages.
 
 
-The pagerduty notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
+* **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
+
+## New Dashboard Grid
+
+The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution.
+
+Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update.
 
 
 ## New Features
 ## New Features
+* **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM)
 * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson)
 * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson)
 * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm)
 * **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda)
 * **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda)
@@ -45,8 +36,11 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details
 * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269)
 * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269)
 * **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda)
 * **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda)
 * **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal)
 * **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal)
+* **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616)
 
 
 ## Minor
 ## Minor
+* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc)
+* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda)
 * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend)
 * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend)
 * **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured.  [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell)
 * **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured.  [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell)
 * **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda)
 * **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda)
@@ -59,16 +53,15 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details
 * **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada)
 * **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada)
 * **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell)
 * **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell)
 
 
-## Tech
-* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
-
-
 ## Fixes
 ## Fixes
 * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand)
 * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand)
 * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu)
 * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu)
 * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm)
 * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm)
 * **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222)
 * **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222)
 
 
+## Tech
+* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
+
 # 4.6.3 (2017-12-14)
 # 4.6.3 (2017-12-14)
 
 
 ## Fixes
 ## Fixes

+ 7 - 1
Gopkg.lock

@@ -412,6 +412,12 @@
   revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
   revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
   version = "1.6.3"
   version = "1.6.3"
 
 
+[[projects]]
+  branch = "master"
+  name = "github.com/teris-io/shortid"
+  packages = ["."]
+  revision = "771a37caa5cf0c81f585d7b6df4dfc77e0615b5c"
+
 [[projects]]
 [[projects]]
   name = "github.com/uber/jaeger-client-go"
   name = "github.com/uber/jaeger-client-go"
   packages = [
   packages = [
@@ -625,6 +631,6 @@
 [solve-meta]
 [solve-meta]
   analyzer-name = "dep"
   analyzer-name = "dep"
   analyzer-version = 1
   analyzer-version = 1
-  inputs-digest = "98e8d8f5fb21fe448aeb3db41c9fed85fe3bf80400e553211cf39a9c05720e01"
+  inputs-digest = "4de68f1342ba98a637ec8ca7496aeeae2021bf9e4c7c80db7924e14709151a62"
   solver-name = "gps-cdcl"
   solver-name = "gps-cdcl"
   solver-version = 1
   solver-version = 1

+ 4 - 0
Gopkg.toml

@@ -193,3 +193,7 @@ ignored = [
   non-go = true
   non-go = true
   go-tests = true
   go-tests = true
   unused-packages = true
   unused-packages = true
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/teris-io/shortid"

+ 1 - 1
circle.yml

@@ -9,7 +9,7 @@ machine:
     GOPATH: "/home/ubuntu/.go_workspace"
     GOPATH: "/home/ubuntu/.go_workspace"
     ORG_PATH: "github.com/grafana"
     ORG_PATH: "github.com/grafana"
     REPO_PATH: "${ORG_PATH}/grafana"
     REPO_PATH: "${ORG_PATH}/grafana"
-    GODIST: "go1.9.2.linux-amd64.tar.gz"
+    GODIST: "go1.9.3.linux-amd64.tar.gz"
   post:
   post:
     - mkdir -p ~/download
     - mkdir -p ~/download
     - mkdir -p ~/docker
     - mkdir -p ~/docker

+ 3 - 0
conf/defaults.ini

@@ -174,6 +174,9 @@ disable_gravatar = false
 # data source proxy whitelist (ip_or_domain:port separated by spaces)
 # data source proxy whitelist (ip_or_domain:port separated by spaces)
 data_source_proxy_whitelist =
 data_source_proxy_whitelist =
 
 
+# disable protection against brute force login attempts
+disable_brute_force_login_protection = false
+
 #################################### Snapshots ###########################
 #################################### Snapshots ###########################
 [snapshots]
 [snapshots]
 # snapshot sharing options
 # snapshot sharing options

+ 3 - 0
conf/sample.ini

@@ -162,6 +162,9 @@ log_queries =
 # data source proxy whitelist (ip_or_domain:port separated by spaces)
 # data source proxy whitelist (ip_or_domain:port separated by spaces)
 ;data_source_proxy_whitelist =
 ;data_source_proxy_whitelist =
 
 
+# disable protection against brute force login attempts
+;disable_brute_force_login_protection = false
+
 #################################### Snapshots ###########################
 #################################### Snapshots ###########################
 [snapshots]
 [snapshots]
 # snapshot sharing options
 # snapshot sharing options

+ 1 - 1
docker/blocks/prometheus/Dockerfile

@@ -1,3 +1,3 @@
-FROM prom/prometheus
+FROM prom/prometheus:v1.8.2
 ADD prometheus.yml /etc/prometheus/
 ADD prometheus.yml /etc/prometheus/
 ADD alert.rules /etc/prometheus/
 ADD alert.rules /etc/prometheus/

+ 1 - 0
docs/Dockerfile

@@ -9,5 +9,6 @@ FROM grafana/docs-base:latest
 
 
 COPY config.toml /site
 COPY config.toml /site
 COPY awsconfig /site
 COPY awsconfig /site
+COPY versions.json /site/static/js
 
 
 VOLUME ["/site/content"]
 VOLUME ["/site/content"]

+ 76 - 0
docs/sources/administration/permissions.md

@@ -0,0 +1,76 @@
++++
+title = "Permissions"
+description = "Grafana user permissions"
+keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"]
+type = "docs"
+aliases = ["/reference/admin"]
+[menu.docs]
+name = "Permissions"
+parent = "admin"
+weight = 3
++++
+
+# Permissions
+
+Grafana users have permissions that are determined by their:
+
+- **Organization Role** (Admin, Editor, Viewer)
+- Via **Team** memberships where the **Team** has been assigned specific permissions.
+- Via permissions assigned directly to user (on folders or dashboards)
+- The Grafana Admin (i.e. Super Admin) user flag.
+
+## Organization Roles
+
+Users can be belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do
+in that organization.
+
+### Admin Role
+
+Can do everything scoped to the organization. For example:
+
+- Add & Edit data data sources.
+- Add & Edit organization users & teams.
+- Configure App plugins & set org settings.
+
+### Editor Role
+
+- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
+- **Cannot** create or edit data sources nor invite new users.
+
+### Viewer Role
+
+- View any dashboard. This can be disabled on specific folders and dashboards.
+- **Cannot** create or edit dashboards nor data sources.
+
+This role can be tweaked via Grafana server setting [viewers_can_edit]({{< relref "installation/configuration.md#viewers-can-edit" >}}). If you set this to true users
+with **Viewer** can also make transient dashboard edits, meaning they can modify panels & queries but not save the changes (nor create new dashboards).
+Useful for public Grafana installations where you want anonymous users to be able to edit panels & queries but not save or create new dashboards.
+
+## Grafana Admin
+
+This admin flag makes a user a `Super Admin`. This means they can access the `Server Admin` views where all users and organizations can be administrated.
+
+### Dashboard & Folder Permissions
+
+> Introduced in Grafana v5.0
+
+{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
+
+For dashboards and dashboard folders there is a **Permissions** page that make it possible to
+remove the default role based permssions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**.
+
+You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**.
+
+Permission levels:
+
+- **Admin**: Can edit & create dashboards and edit permissions.
+- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
+- **View**: Can only view existing dashboars/folders.
+
+#### Restricting access
+
+The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the
+Access Control List (ACL).
+
+- You cannot override permissions for users with **Org Admin Role**
+- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.

+ 1 - 1
docs/sources/administration/provisioning.md

@@ -3,6 +3,7 @@ title = "Provisioning"
 description = ""
 description = ""
 keywords = ["grafana", "provisioning"]
 keywords = ["grafana", "provisioning"]
 type = "docs"
 type = "docs"
+aliases = ["/installation/provisioning"]
 [menu.docs]
 [menu.docs]
 parent = "admin"
 parent = "admin"
 weight = 8
 weight = 8
@@ -66,7 +67,6 @@ Tool | Project
 -----|------------
 -----|------------
 Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
 Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
 Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
 Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
-Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
 Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
 Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
 Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
 Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
 
 

+ 1 - 1
docs/sources/features/index.md

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 [menu.docs]
 name = "Features"
 name = "Features"
 identifier = "features"
 identifier = "features"
-weight = 3
+weight = 4
 +++
 +++
 
 
 
 

+ 1 - 0
docs/sources/guides/basic_concepts.md

@@ -7,6 +7,7 @@ type = "docs"
 name = "Basic Concepts"
 name = "Basic Concepts"
 identifier = "basic_concepts"
 identifier = "basic_concepts"
 parent = "guides"
 parent = "guides"
+weight = 2
 +++
 +++
 
 
 # Basic Concepts
 # Basic Concepts

+ 2 - 1
docs/sources/guides/getting_started.md

@@ -8,6 +8,7 @@ aliases = ["/guides/gettingstarted"]
 name = "Getting Started"
 name = "Getting Started"
 identifier = "getting_started_guide"
 identifier = "getting_started_guide"
 parent = "guides"
 parent = "guides"
+weight = 1
 +++
 +++
 
 
 # Getting started
 # Getting started
@@ -24,7 +25,7 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course
 
 
 ### Top header
 ### Top header
 
 
-Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard. 
+Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard.
 
 
 <img class="no-shadow" src="/img/docs/v45/top_nav_annotated.png">
 <img class="no-shadow" src="/img/docs/v45/top_nav_annotated.png">
 
 

+ 1 - 1
docs/sources/guides/index.md

@@ -4,6 +4,6 @@ type = "docs"
 [menu.docs]
 [menu.docs]
 name = "Getting Started"
 name = "Getting Started"
 identifier = "guides"
 identifier = "guides"
-weight = 2
+weight = 3
 +++
 +++
 
 

+ 120 - 0
docs/sources/guides/whats-new-in-v5.md

@@ -0,0 +1,120 @@
++++
+title = "What's New in Grafana v5.0"
+description = "Feature & improvement highlights for Grafana v5.0"
+keywords = ["grafana", "new", "documentation", "5.0"]
+type = "docs"
+[menu.docs]
+name = "Version 5.0"
+identifier = "v5.0"
+parent = "whatsnew"
+weight = -6
++++
+
+# What's New in Grafana v5.0
+
+This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements.
+
+- [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts.
+- [New UX]({{< relref "#new-ux-layout-engine" >}}). The UI has big improvements in both look and function.
+- [New Light Theme]({{< relref "#new-light-theme" >}}) is now looking really nice.
+- [Dashboard Folders]({{< relref "#dashboard-folders" >}}) helps you keep your dashboards organized.
+- [Permissions]({{< relref "#dashboard-folders" >}}) on folders and dashboards helps manage larger Grafana installations.
+- [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system.
+- [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files.
+- [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files.
+
+### Video showing new features
+
+<iframe height="215" src="https://www.youtube.com/embed/BC_YRNpqj5k?rel=0&amp;showinfo=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
+<br />
+
+## New Dashboard Layout Engine
+
+{{< docs-imagebox img="/img/docs/v50/new_grid.png" max-width="1000px" class="docs-image--right">}}
+
+The new dashboard layout engine allows for much easier movement and sizing of panels, as other panels now move out of the way in
+a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens
+up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video
+above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the
+new position system and look close to identical. The new panel position makes dashboards saved in v5.0 not compatible
+with older versions of Grafana.
+
+<div class="clearfix"></div>
+
+## New UX
+
+{{< docs-imagebox img="/img/docs/v50/new_ux_nav.png" max-width="1000px" class="docs-image--right" >}}
+
+Almost every page has seen significant UX improvements. All pages (except dashboard pages) have a new tab-based layout that improves navigation between pages. The side menu has also changed quite a bit. You can still hide the side menu completely if you click on the Grafana logo.
+
+<div class="clearfix"></div>
+
+### Dashboard Settings
+
+{{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}}
+Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard
+settings views have been combined with a side nav which allows you to easily move between different setting categories.
+
+<div class="clearfix"></div>
+
+## New Light Theme
+
+{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}}
+
+This theme has not seen a lot of love in recent years and we felt it was time to rework it and give it a major overhaul. We are very happy with the result.
+
+<div class="clearfix"></div>
+
+## Dashboard Folders
+
+{{< docs-imagebox img="/img/docs/v50/new_search.png" max-width="1000px" class="docs-image--right" >}}
+
+The big new feature that comes with Grafana v5.0 is dashboard folders. Now you can organize your dashboards in folders,
+which is very useful if you have a lot of dashboards or multiple teams.
+
+- New search design adds expandable sections for each folder, starred and recently viewed dashboards.
+- New manage dashboard pages enable batch actions and views for folder settings and permissions.
+- Set permissions on folders and have dashboards inherit the permissions.
+
+## Teams
+
+A team is a new concept in Grafana v5. They are simply a group of users that can be then be used in the new permission system for dashboards and folders. Only an admin can create teams.
+We hope to do more with teams in future releases like integration with LDAP and a team landing page.
+
+## Permissions
+
+{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}}
+
+You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and replaced with specific teams or users enabling more control over what a user can see and edit.
+
+<div class="clearfix"></div>
+
+# Provisioning from configuration
+
+In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
+But that required the service to be running before you started creating dashboards and you also needed to
+set up credentials for the HTTP API. In 5.0 we decided to improve this experience by adding a new active
+provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can
+be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs
+and alerts as well.
+
+### Data sources
+
+Data sources can now be setup using config files. These data sources are by default not editable from the Grafana GUI.
+It's also possible to update and delete data sources from the config file. More info in the [data source provisioning docs](/administration/provisioning/#datasources).
+
+### Dashboards
+
+We also deprecated the [dashboard.json] in favor of our new dashboard provisioner that keeps dashboards on disk
+in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old
+[dashboard.json] feature. Instead of storing the dashboard in memory we now insert the dashboard into the database,
+which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
+expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
+
+# Dashboard model & API
+
+We are introducing a new identifier (`uid`) in the dashboard JSON model. The new identifier will be a 9-12 character long unique id.
+We are also changing the route for getting dashboards to use this `uid` instead of the slug that the current route and API are using.
+We will keep supporting the old route for backward compatibility. This will make it possible to change the title on dashboards without breaking links.
+Sharing dashboards between instances becomes much easier since the uid is unique (unique enough). This might seem like a small change,
+but we are incredibly excited about it since it will make it much easier to manage, collaborate and navigate between dashboards.

+ 1 - 5
docs/sources/index.md

@@ -4,10 +4,6 @@ description = "Install guide for Grafana"
 keywords = ["grafana", "installation", "documentation"]
 keywords = ["grafana", "installation", "documentation"]
 type = "docs"
 type = "docs"
 aliases = ["v1.1", "guides/reference/admin"]
 aliases = ["v1.1", "guides/reference/admin"]
-[menu.docs]
-name = "Welcome to the Docs"
-identifier = "root"
-weight = -1
 +++
 +++
 
 
 # Welcome to the Grafana Documentation
 # Welcome to the Grafana Documentation
@@ -22,7 +18,7 @@ other domains including industrial sensors, home automation, weather, and proces
 - [Installing on Mac OS X](installation/mac)
 - [Installing on Mac OS X](installation/mac)
 - [Installing on Windows](installation/windows)
 - [Installing on Windows](installation/windows)
 - [Installing on Docker](installation/docker)
 - [Installing on Docker](installation/docker)
-- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](installation/provisioning)
+- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](administration/provisioning#configuration-management-tools)
 - [Nightly Builds](https://grafana.com/grafana/download)
 - [Nightly Builds](https://grafana.com/grafana/download)
 
 
 For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}})
 For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}})

+ 0 - 25
docs/sources/installation/configuration.md

@@ -671,31 +671,6 @@ session provider you have configured.
 - **memcache:** ex:  127.0.0.1:11211
 - **memcache:** ex:  127.0.0.1:11211
 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`
 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`
 
 
-If you use MySQL or Postgres as the session store you need to create the
-session table manually.
-
-Mysql Example:
-
-```bash
-CREATE TABLE `session` (
-    `key`       CHAR(16) NOT NULL,
-    `data`      BLOB,
-    `expiry`    INT(11) UNSIGNED NOT NULL,
-    PRIMARY KEY (`key`)
-) ENGINE=MyISAM DEFAULT CHARSET=utf8;
-```
-
-Postgres Example:
-
-```bash
-CREATE TABLE session (
-    key       CHAR(16) NOT NULL,
-    data      BYTEA,
-    expiry    INTEGER NOT NULL,
-    PRIMARY KEY (key)
-);
-```
-
 Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default).
 Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default).
 
 
 ### cookie_name
 ### cookie_name

+ 1 - 0
docs/sources/installation/index.md

@@ -7,6 +7,7 @@ aliases = ["installation/installation/", "v2.1/installation/install/"]
 [menu.docs]
 [menu.docs]
 name = "Installation"
 name = "Installation"
 identifier = "installation"
 identifier = "installation"
+weight = 1
 +++
 +++
 
 
 ## Installing Grafana
 ## Installing Grafana

+ 0 - 42
docs/sources/reference/admin.md

@@ -1,42 +0,0 @@
-+++
-title = "Admin Roles"
-description = "Users & Organization permission and administration"
-keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"]
-type = "docs"
-[menu.docs]
-name = "Admin Roles"
-parent = "admin"
-weight = 3
-+++
-
-# Administration
-
-Grafana has two levels of administrators:
-
-* Organizational administrators: These admins can manage users within specific organizations in a particular Grafana installation
-* Grafana administrators: These super admins can manage users across all organizations in a Grafana installation. They can also change and access system-wide settings.
-
-## Organizational Administrators
-
-As an Organizational administrator, you can add `Data Sources`, add Users to your Organization and
-modify Organization details and options.
-
-> *Note*: If Grafana is configured with `users.allow_org_create = true`, any User of any Organization will be able to
-> start their own Organization and become the administrator of that Organization.
-
-
-## Grafana Administrators
-
-<img src="/img/v2/admin_sidenav.png" class="pull-right" style="margin-left: 15px">
-As a Grafana Administrator, you have complete access to any Organization or User in that instance of Grafana.
-When performing actions as a Grafana admin, the sidebar will change it's appearance as below to indicate you are performing global server administration.
-
-From the Grafana Server Admin page, you can access the System Info page which summarizes all of the backend configuration settings of the Grafana server.
-
-## Why would I have multiple Organizations?
-
-Organizations in Grafana are best suited for a **multi-tenant deployment**. In a multi-tenant deployment,
-Organizations can be used to provide a full Grafana experience to different sets of users from a single Grafana instance,
-at the convenience of the Grafana Administrator.
-
-In most cases, a Grafana installation will only have **one** Organization. Since dashboards, data sources and other configuration items are not shared between organizations, there's no need to create multiple Organizations if you want all your users to have access to the same set of dashboards and data.

+ 1 - 1
docs/sources/reference/sharing.md

@@ -39,7 +39,7 @@ Click a panel title to open the panel menu, then click share in the panel menu t
 
 
 ### Direct Link Rendered Image
 ### Direct Link Rendered Image
 
 
-You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `vendor/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available.
+You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `tools/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available.
 
 
 Example of a link to a server-side rendered PNG:
 Example of a link to a server-side rendered PNG:
 
 

+ 1 - 1
docs/sources/features/whatsnew/index.md → docs/sources/whatsnew/index.md

@@ -3,7 +3,7 @@ title = "What's New in Grafana"
 [menu.docs]
 [menu.docs]
 name = "What's New In Grafana"
 name = "What's New In Grafana"
 identifier = "whatsnew"
 identifier = "whatsnew"
-weight = 2
+weight = 3
 +++
 +++
 
 
 
 

+ 9 - 0
docs/versions.json

@@ -0,0 +1,9 @@
+[
+  { "version": "v5.0", "path": "/v5.0", "archived": false },
+  { "version": "v4.6", "path": "/",     "archived": false, "current": true },
+  { "version": "v4.5", "path": "/v4.5", "archived": true },
+  { "version": "v4.4", "path": "/v4.4", "archived": true },
+  { "version": "v4.3", "path": "/v4.3", "archived": true },
+  { "version": "v4.1", "path": "/v4.1", "archived": true },
+  { "version": "v3.1", "path": "/v3.1", "archived": true }
+]

+ 5 - 0
package.json

@@ -115,6 +115,10 @@
     "*.scss": [
     "*.scss": [
       "prettier --write",
       "prettier --write",
       "git add"
       "git add"
+    ],
+    "*.go": [
+      "gofmt -w -s",
+      "git add"
     ]
     ]
   },
   },
   "prettier": {
   "prettier": {
@@ -153,6 +157,7 @@
     "react-popper": "^0.7.5",
     "react-popper": "^0.7.5",
     "react-select": "^1.1.0",
     "react-select": "^1.1.0",
     "react-sizeme": "^2.3.6",
     "react-sizeme": "^2.3.6",
+    "react-transition-group": "^2.2.1",
     "remarkable": "^1.7.1",
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",
     "rxjs": "^5.4.3",

+ 55 - 25
pkg/api/alerting.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/alerting"
 	"github.com/grafana/grafana/pkg/services/alerting"
+	"github.com/grafana/grafana/pkg/services/guardian"
 )
 )
 
 
 func ValidateOrgAlert(c *middleware.Context) {
 func ValidateOrgAlert(c *middleware.Context) {
@@ -62,9 +63,22 @@ func GetAlerts(c *middleware.Context) Response {
 		return ApiError(500, "List alerts failed", err)
 		return ApiError(500, "List alerts failed", err)
 	}
 	}
 
 
+	alertDTOs, resp := transformToDTOs(query.Result, c)
+	if resp != nil {
+		return resp
+	}
+
+	return Json(200, alertDTOs)
+}
+
+func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) {
+	if len(alerts) == 0 {
+		return []*dtos.AlertRule{}, nil
+	}
+
 	dashboardIds := make([]int64, 0)
 	dashboardIds := make([]int64, 0)
 	alertDTOs := make([]*dtos.AlertRule, 0)
 	alertDTOs := make([]*dtos.AlertRule, 0)
-	for _, alert := range query.Result {
+	for _, alert := range alerts {
 		dashboardIds = append(dashboardIds, alert.DashboardId)
 		dashboardIds = append(dashboardIds, alert.DashboardId)
 		alertDTOs = append(alertDTOs, &dtos.AlertRule{
 		alertDTOs = append(alertDTOs, &dtos.AlertRule{
 			Id:             alert.Id,
 			Id:             alert.Id,
@@ -83,22 +97,40 @@ func GetAlerts(c *middleware.Context) Response {
 		DashboardIds: dashboardIds,
 		DashboardIds: dashboardIds,
 	}
 	}
 
 
-	if len(alertDTOs) > 0 {
-		if err := bus.Dispatch(&dashboardsQuery); err != nil {
-			return ApiError(500, "List alerts failed", err)
-		}
+	if err := bus.Dispatch(&dashboardsQuery); err != nil {
+		return nil, ApiError(500, "List alerts failed", err)
 	}
 	}
 
 
 	//TODO: should be possible to speed this up with lookup table
 	//TODO: should be possible to speed this up with lookup table
 	for _, alert := range alertDTOs {
 	for _, alert := range alertDTOs {
 		for _, dash := range dashboardsQuery.Result {
 		for _, dash := range dashboardsQuery.Result {
 			if alert.DashboardId == dash.Id {
 			if alert.DashboardId == dash.Id {
-				alert.DashbboardUri = "db/" + dash.Slug
+				alert.DashbboardUri = dash.GenerateUrl()
+				break
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	return Json(200, alertDTOs)
+	permissionsQuery := models.GetDashboardPermissionsForUserQuery{
+		DashboardIds: dashboardIds,
+		OrgId:        c.OrgId,
+		UserId:       c.SignedInUser.UserId,
+		OrgRole:      c.SignedInUser.OrgRole,
+	}
+
+	if err := bus.Dispatch(&permissionsQuery); err != nil {
+		return nil, ApiError(500, "List alerts failed", err)
+	}
+
+	for _, alert := range alertDTOs {
+		for _, perm := range permissionsQuery.Result {
+			if alert.DashboardId == perm.DashboardId {
+				alert.CanEdit = perm.Permission > 1
+			}
+		}
+	}
+
+	return alertDTOs, nil
 }
 }
 
 
 // POST /api/alerts/test
 // POST /api/alerts/test
@@ -155,24 +187,6 @@ func GetAlert(c *middleware.Context) Response {
 	return Json(200, &query.Result)
 	return Json(200, &query.Result)
 }
 }
 
 
-// DEL /api/alerts/:id
-func DelAlert(c *middleware.Context) Response {
-	alertId := c.ParamsInt64(":alertId")
-
-	if alertId == 0 {
-		return ApiError(401, "Failed to parse alertid", nil)
-	}
-
-	cmd := models.DeleteAlertCommand{AlertId: alertId}
-
-	if err := bus.Dispatch(&cmd); err != nil {
-		return ApiError(500, "Failed to delete alert", err)
-	}
-
-	var resp = map[string]interface{}{"alertId": alertId}
-	return Json(200, resp)
-}
-
 func GetAlertNotifiers(c *middleware.Context) Response {
 func GetAlertNotifiers(c *middleware.Context) Response {
 	return Json(200, alerting.GetNotifiers())
 	return Json(200, alerting.GetNotifiers())
 }
 }
@@ -267,6 +281,22 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R
 //POST /api/alerts/:alertId/pause
 //POST /api/alerts/:alertId/pause
 func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
 func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
 	alertId := c.ParamsInt64("alertId")
 	alertId := c.ParamsInt64("alertId")
+
+	query := models.GetAlertByIdQuery{Id: alertId}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Get Alert failed", err)
+	}
+
+	guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser)
+	if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
+		if err != nil {
+			return ApiError(500, "Error while checking permissions for Alert", err)
+		}
+
+		return ApiError(403, "Access denied to this dashboard and alert", nil)
+	}
+
 	cmd := models.PauseAlertCommand{
 	cmd := models.PauseAlertCommand{
 		OrgId:    c.OrgId,
 		OrgId:    c.OrgId,
 		AlertIds: []int64{alertId},
 		AlertIds: []int64{alertId},

+ 97 - 0
pkg/api/alerting_test.go

@@ -0,0 +1,97 @@
+package api
+
+import (
+	"testing"
+
+	"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/smartystreets/goconvey/convey"
+)
+
+func TestAlertingApiEndpoint(t *testing.T) {
+	Convey("Given an alert in a dashboard with an acl", t, func() {
+
+		singleAlert := &m.Alert{Id: 1, DashboardId: 1, Name: "singlealert"}
+
+		bus.AddHandler("test", func(query *m.GetAlertByIdQuery) error {
+			query.Result = singleAlert
+			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
+		})
+
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = []*m.Team{}
+			return nil
+		})
+
+		Convey("When user is editor and not in the ACL", func() {
+			Convey("Should not be able to pause the alert", func() {
+				cmd := dtos.PauseAlertCommand{
+					AlertId: 1,
+					Paused:  true,
+				}
+				postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+					CallPauseAlert(sc)
+					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 pause the alert", func() {
+				cmd := dtos.PauseAlertCommand{
+					AlertId: 1,
+					Paused:  true,
+				}
+				postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+					CallPauseAlert(sc)
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+		})
+	})
+}
+
+func CallPauseAlert(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.PauseAlertCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func postAlertScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PauseAlertCommand, 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 PauseAlert(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 2 - 31
pkg/api/annotations_test.go

@@ -1,16 +1,13 @@
 package api
 package api
 
 
 import (
 import (
-	"path/filepath"
 	"testing"
 	"testing"
 
 
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/annotations"
 	"github.com/grafana/grafana/pkg/services/annotations"
-	macaron "gopkg.in/macaron.v1"
 
 
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
@@ -202,20 +199,7 @@ func postAnnotationScenario(desc string, url string, routePattern string, role m
 	Convey(desc+" "+url, func() {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
-
+		sc := setupScenarioContext(url)
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 			sc.context = c
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.UserId = TestUserID
@@ -238,20 +222,7 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m.
 	Convey(desc+" "+url, func() {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
-
+		sc := setupScenarioContext(url)
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 			sc.context = c
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.UserId = TestUserID

+ 13 - 3
pkg/api/api.go

@@ -15,6 +15,8 @@ func (hs *HttpServer) registerRoutes() {
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
 	reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
 	reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+	redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl()
+	redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl()
 	quota := middleware.Quota
 	quota := middleware.Quota
 	bind := binding.Bind
 	bind := binding.Bind
 
 
@@ -63,9 +65,13 @@ func (hs *HttpServer) registerRoutes() {
 	r.Get("/plugins/:id/edit", reqSignedIn, Index)
 	r.Get("/plugins/:id/edit", reqSignedIn, Index)
 	r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
 	r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
 
 
-	r.Get("/dashboard/*", reqSignedIn, Index)
+	r.Get("/d/:uid/:slug", reqSignedIn, Index)
+	r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index)
+	r.Get("/dashboard/script/*", reqSignedIn, Index)
 	r.Get("/dashboard-solo/snapshot/*", Index)
 	r.Get("/dashboard-solo/snapshot/*", Index)
-	r.Get("/dashboard-solo/*", reqSignedIn, Index)
+	r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
+	r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloUrl, Index)
+	r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
 	r.Get("/import/dashboard", reqSignedIn, Index)
 	r.Get("/import/dashboard", reqSignedIn, Index)
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
@@ -242,7 +248,6 @@ func (hs *HttpServer) registerRoutes() {
 
 
 		// Folders
 		// Folders
 		apiRoute.Group("/folders", func(folderRoute RouteRegister) {
 		apiRoute.Group("/folders", func(folderRoute RouteRegister) {
-			folderRoute.Get("/", wrap(GetFolders))
 			folderRoute.Get("/:id", wrap(GetFolderById))
 			folderRoute.Get("/:id", wrap(GetFolderById))
 			folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
 			folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
 			folderRoute.Put("/:id", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
 			folderRoute.Put("/:id", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
@@ -251,6 +256,9 @@ func (hs *HttpServer) registerRoutes() {
 
 
 		// Dashboard
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
+			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
+			dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUid))
+
 			dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
 			dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
 			dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
 			dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
 
 
@@ -261,6 +269,8 @@ func (hs *HttpServer) registerRoutes() {
 			dashboardRoute.Get("/tags", GetDashboardTags)
 			dashboardRoute.Get("/tags", GetDashboardTags)
 			dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
 			dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
 
 
+			dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser))
+
 			dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) {
 			dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) {
 				dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
 				dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))

+ 105 - 0
pkg/api/common_test.go

@@ -0,0 +1,105 @@
+package api
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+
+	"github.com/go-macaron/session"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/models"
+	macaron "gopkg.in/macaron.v1"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
+	loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn)
+}
+
+func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.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.UserId = TestUserID
+			sc.context.OrgId = TestOrgID
+			sc.context.OrgRole = role
+			if sc.handlerFunc != nil {
+				return sc.handlerFunc(sc.context)
+			}
+
+			return nil
+		})
+
+		switch method {
+		case "GET":
+			sc.m.Get(routePattern, sc.defaultHandler)
+		case "DELETE":
+			sc.m.Delete(routePattern, sc.defaultHandler)
+		}
+
+		fn(sc)
+	})
+}
+
+func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
+func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	q := req.URL.Query()
+	for k, v := range queryParams {
+		q.Add(k, v)
+	}
+	req.URL.RawQuery = q.Encode()
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
+type scenarioContext struct {
+	m              *macaron.Macaron
+	context        *middleware.Context
+	resp           *httptest.ResponseRecorder
+	handlerFunc    handlerFunc
+	defaultHandler macaron.Handler
+	req            *http.Request
+	url            string
+}
+
+func (sc *scenarioContext) exec() {
+	sc.m.ServeHTTP(sc.resp, sc.req)
+}
+
+type scenarioFunc func(c *scenarioContext)
+type handlerFunc func(c *middleware.Context) Response
+
+func setupScenarioContext(url string) *scenarioContext {
+	sc := &scenarioContext{
+		url: url,
+	}
+	viewsPath, _ := filepath.Abs("../../public/views")
+
+	sc.m = macaron.New()
+	sc.m.Use(macaron.Renderer(macaron.RenderOptions{
+		Directory: viewsPath,
+		Delims:    macaron.Delims{Left: "[[", Right: "]]"},
+	}))
+
+	sc.m.Use(middleware.GetContextHandler())
+	sc.m.Use(middleware.Sessioner(&session.Options{}))
+
+	return sc
+}

+ 56 - 9
pkg/api/dashboard.go

@@ -44,14 +44,13 @@ func dashboardGuardianResponse(err error) Response {
 }
 }
 
 
 func GetDashboard(c *middleware.Context) Response {
 func GetDashboard(c *middleware.Context) Response {
-	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid"))
 	if rsp != nil {
 	if rsp != nil {
 		return rsp
 		return rsp
 	}
 	}
 
 
 	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
 	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
 	if canView, err := guardian.CanView(); err != nil || !canView {
 	if canView, err := guardian.CanView(); err != nil || !canView {
-		fmt.Printf("%v", err)
 		return dashboardGuardianResponse(err)
 		return dashboardGuardianResponse(err)
 	}
 	}
 
 
@@ -90,6 +89,7 @@ func GetDashboard(c *middleware.Context) Response {
 		IsFolder:    dash.IsFolder,
 		IsFolder:    dash.IsFolder,
 		FolderId:    dash.FolderId,
 		FolderId:    dash.FolderId,
 		FolderTitle: "Root",
 		FolderTitle: "Root",
+		Url:         dash.GetUrl(),
 	}
 	}
 
 
 	// lookup folder title
 	// lookup folder title
@@ -125,8 +125,15 @@ func getUserLogin(userId int64) string {
 	}
 	}
 }
 }
 
 
-func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) {
-	query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
+func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dashboard, Response) {
+	var query m.GetDashboardQuery
+
+	if len(uid) > 0 {
+		query = m.GetDashboardQuery{Uid: uid, Id: id, OrgId: orgId}
+	} else {
+		query = m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
+	}
+
 	if err := bus.Dispatch(&query); err != nil {
 	if err := bus.Dispatch(&query); err != nil {
 		return nil, ApiError(404, "Dashboard not found", err)
 		return nil, ApiError(404, "Dashboard not found", err)
 	}
 	}
@@ -139,7 +146,37 @@ func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Respo
 }
 }
 
 
 func DeleteDashboard(c *middleware.Context) Response {
 func DeleteDashboard(c *middleware.Context) Response {
-	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	query := m.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to retrieve dashboards by slug", err)
+	}
+
+	if len(query.Result) > 1 {
+		return Json(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()})
+	}
+
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, "")
+	if rsp != nil {
+		return rsp
+	}
+
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
+	cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id}
+	if err := bus.Dispatch(&cmd); err != nil {
+		return ApiError(500, "Failed to delete dashboard", err)
+	}
+
+	var resp = map[string]interface{}{"title": dash.Title}
+	return Json(200, resp)
+}
+
+func DeleteDashboardByUid(c *middleware.Context) Response {
+	dash, rsp := getDashboardHelper(c.OrgId, "", 0, c.Params(":uid"))
 	if rsp != nil {
 	if rsp != nil {
 		return rsp
 		return rsp
 	}
 	}
@@ -170,7 +207,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	if dashId == 0 {
 	if dashId == 0 {
 		dashId = cmd.FolderId
 		dashId = cmd.FolderId
 	} else {
 	} else {
-		_, rsp := getDashboardHelper(c.OrgId, "", dashId)
+		_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
 		if rsp != nil {
 		if rsp != nil {
 			return rsp
 			return rsp
 		}
 		}
@@ -219,7 +256,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {
-		if err == m.ErrDashboardWithSameNameExists {
+		if err == m.ErrDashboardWithSameUIDExists {
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 		}
 		}
 		if err == m.ErrDashboardVersionMismatch {
 		if err == m.ErrDashboardVersionMismatch {
@@ -243,8 +280,17 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 	}
 	}
 
 
+	dashboard.IsFolder = dash.IsFolder
+
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
-	return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id})
+	return Json(200, util.DynMap{
+		"status":  "success",
+		"slug":    dashboard.Slug,
+		"version": dashboard.Version,
+		"id":      dashboard.Id,
+		"uid":     dashboard.Uid,
+		"url":     dashboard.GetUrl(),
+	})
 }
 }
 
 
 func GetHomeDashboard(c *middleware.Context) Response {
 func GetHomeDashboard(c *middleware.Context) Response {
@@ -411,7 +457,7 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff
 
 
 // RestoreDashboardVersion restores a dashboard to the given version.
 // RestoreDashboardVersion restores a dashboard to the given version.
 func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
 func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
-	dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"))
+	dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"), "")
 	if rsp != nil {
 	if rsp != nil {
 		return rsp
 		return rsp
 	}
 	}
@@ -434,6 +480,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard
 	saveCmd.UserId = c.UserId
 	saveCmd.UserId = c.UserId
 	saveCmd.Dashboard = version.Data
 	saveCmd.Dashboard = version.Data
 	saveCmd.Dashboard.Set("version", dash.Version)
 	saveCmd.Dashboard.Set("version", dash.Version)
+	saveCmd.Dashboard.Set("uid", dash.Uid)
 	saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
 	saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
 
 
 	return PostDashboard(c, saveCmd)
 	return PostDashboard(c, saveCmd)

+ 14 - 22
pkg/api/dashboard_acl_test.go

@@ -1,16 +1,13 @@
 package api
 package api
 
 
 import (
 import (
-	"path/filepath"
 	"testing"
 	"testing"
 
 
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
-	macaron "gopkg.in/macaron.v1"
 
 
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
@@ -42,6 +39,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			return nil
 			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() {
 		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) {
 			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() {
 				Convey("Should be able to access ACL", func() {
@@ -59,7 +62,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
-		Convey("When user is editor and has admin permission in the ACL", func() {
+		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) {
 			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{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 
 
@@ -150,11 +153,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
-		Convey("When user is editor 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_EDITOR, func(sc *scenarioContext) {
+		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{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 
 
-				Convey("Should not be able to access ACL", func() {
+				// Getting the permissions is an Admin permission
+				Convey("Should not be able to get list of permissions from ACL", func() {
 					sc.handlerFunc = GetDashboardAclList
 					sc.handlerFunc = GetDashboardAclList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
@@ -162,7 +166,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) {
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 
 
 				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
 				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
@@ -178,7 +182,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
-		Convey("When user is editor and not in the ACL", func() {
+		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) {
 			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() {
 				Convey("Should not be able to access ACL", func() {
@@ -236,19 +240,7 @@ func postAclScenario(desc string, url string, routePattern string, role m.RoleTy
 	Convey(desc+" "+url, func() {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
+		sc := setupScenarioContext(url)
 
 
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 			sc.context = c
 			sc.context = c

+ 395 - 42
pkg/api/dashboard_test.go

@@ -2,12 +2,8 @@ package api
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
-	"path/filepath"
 	"testing"
 	"testing"
 
 
-	macaron "gopkg.in/macaron.v1"
-
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -32,14 +28,27 @@ func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem)
 
 
 var fakeRepo *fakeDashboardRepo
 var fakeRepo *fakeDashboardRepo
 
 
+// This tests two main scenarios. If a user has access to execute an action on a dashboard:
+// 1. and the dashboard is in a folder which does not have an acl
+// 2. and the dashboard is in a folder which does have an acl
+
 func TestDashboardApiEndpoint(t *testing.T) {
 func TestDashboardApiEndpoint(t *testing.T) {
 	Convey("Given a folder", t, func() {
 	Convey("Given a folder", t, func() {
 		fakeFolder := m.NewDashboardFolder("Folder")
 		fakeFolder := m.NewDashboardFolder("Folder")
 		fakeFolder.Id = 1
 		fakeFolder.Id = 1
 		fakeFolder.HasAcl = false
 		fakeFolder.HasAcl = false
 
 
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{fakeFolder}
+			query.Result = dashboards
+			return nil
+		})
+
+		var getDashboardQueries []*m.GetDashboardQuery
+
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeFolder
 			query.Result = fakeFolder
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 			return nil
 		})
 		})
 
 
@@ -54,9 +63,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Editor", func() {
 		Convey("When user is an Org Editor", func() {
 			role := m.ROLE_EDITOR
 			role := m.ROLE_EDITOR
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/1", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				CallGetDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 404)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				CallGetDashboard(sc)
 				CallGetDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 404)
 				So(sc.resp.Code, ShouldEqual, 404)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
@@ -64,9 +86,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				So(sc.resp.Code, ShouldEqual, 404)
 				So(sc.resp.Code, ShouldEqual, 404)
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/1", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 404)
 				So(sc.resp.Code, ShouldEqual, 404)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 404)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 		})
 		})
 	})
 	})
@@ -77,8 +112,17 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.FolderId = 1
 		fakeDash.FolderId = 1
 		fakeDash.HasAcl = false
 		fakeDash.HasAcl = false
 
 
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{fakeDash}
+			query.Result = dashboards
+			return nil
+		})
+
+		var getDashboardQueries []*m.GetDashboardQuery
+
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash
 			query.Result = fakeDash
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 			return nil
 		})
 		})
 
 
@@ -108,12 +152,34 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			}),
 			}),
 		}
 		}
 
 
+		// This tests two scenarios:
+		// 1. user is an org viewer
+		// 2. user is an org editor
+
 		Convey("When user is an Org Viewer", func() {
 		Convey("When user is an Org Viewer", func() {
 			role := m.ROLE_VIEWER
 			role := m.ROLE_VIEWER
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should not be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeFalse)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 				dash := GetDashboardShouldReturn200(sc)
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should not be able to edit or save dashboard", func() {
 				Convey("Should not be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
@@ -121,9 +187,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -145,9 +224,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Editor", func() {
 		Convey("When user is an Org Editor", func() {
 			role := m.ROLE_EDITOR
 			role := m.ROLE_EDITOR
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeTrue)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 				dash := GetDashboardShouldReturn200(sc)
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to edit or save dashboard", func() {
 				Convey("Should be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
@@ -155,9 +252,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
 				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -171,8 +281,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 			})
 
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
-				CallPostDashboard(sc)
-				So(sc.resp.Code, ShouldEqual, 200)
+				CallPostDashboardShouldReturnSuccess(sc)
 			})
 			})
 
 
 			Convey("When saving a dashboard folder in another folder", func() {
 			Convey("When saving a dashboard folder in another folder", func() {
@@ -206,6 +315,12 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.HasAcl = true
 		fakeDash.HasAcl = true
 		setting.ViewersCanEdit = false
 		setting.ViewersCanEdit = false
 
 
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{fakeDash}
+			query.Result = dashboards
+			return nil
+		})
+
 		aclMockResp := []*m.DashboardAclInfoDTO{
 		aclMockResp := []*m.DashboardAclInfoDTO{
 			{
 			{
 				DashboardId: 1,
 				DashboardId: 1,
@@ -219,8 +334,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 			return nil
 		})
 		})
 
 
+		var getDashboardQueries []*m.GetDashboardQuery
+
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash
 			query.Result = fakeDash
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 			return nil
 		})
 		})
 
 
@@ -238,21 +356,59 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			}),
 			}),
 		}
 		}
 
 
+		// This tests six scenarios:
+		// 1. user is an org viewer AND has no permissions for this dashboard
+		// 2. user is an org editor AND has no permissions for this dashboard
+		// 3. user is an org viewer AND has been granted edit permission for the dashboard
+		// 4. user is an org viewer AND all viewers have edit permission for this dashboard
+		// 5. user is an org viewer AND has been granted an admin permission
+		// 6. user is an org editor AND has been granted a view permission
+
 		Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
 		Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
 			role := m.ROLE_VIEWER
 			role := m.ROLE_VIEWER
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				sc.handlerFunc = GetDashboard
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be denied access", func() {
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				sc.handlerFunc = GetDashboard
 				sc.handlerFunc = GetDashboard
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be denied access", func() {
 				Convey("Should be denied access", func() {
 					So(sc.resp.Code, ShouldEqual, 403)
 					So(sc.resp.Code, ShouldEqual, 403)
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -274,18 +430,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
 		Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
 			role := m.ROLE_EDITOR
 			role := m.ROLE_EDITOR
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				sc.handlerFunc = GetDashboard
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be denied access", func() {
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				sc.handlerFunc = GetDashboard
 				sc.handlerFunc = GetDashboard
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be denied access", func() {
 				Convey("Should be denied access", func() {
 					So(sc.resp.Code, ShouldEqual, 403)
 					So(sc.resp.Code, ShouldEqual, 403)
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -316,9 +502,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 				dash := GetDashboardShouldReturn200(sc)
 
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to get dashboard with edit rights", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeTrue)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to get dashboard with edit rights", func() {
 				Convey("Should be able to get dashboard with edit rights", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
@@ -326,9 +530,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
 				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -342,8 +559,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 			})
 
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
-				CallPostDashboard(sc)
-				So(sc.resp.Code, ShouldEqual, 200)
+				CallPostDashboardShouldReturnSuccess(sc)
 			})
 			})
 		})
 		})
 
 
@@ -360,9 +576,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 				dash := GetDashboardShouldReturn200(sc)
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
 				Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
@@ -370,9 +604,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 		})
 		})
 
 
@@ -388,9 +635,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to get dashboard with edit rights", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeTrue)
+					So(dash.Meta.CanAdmin, ShouldBeTrue)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 				dash := GetDashboardShouldReturn200(sc)
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to get dashboard with edit rights", func() {
 				Convey("Should be able to get dashboard with edit rights", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
@@ -398,9 +663,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
 				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -414,8 +692,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 			})
 
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
-				CallPostDashboard(sc)
-				So(sc.resp.Code, ShouldEqual, 200)
+				CallPostDashboardShouldReturnSuccess(sc)
 			})
 			})
 		})
 		})
 
 
@@ -431,18 +708,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should not be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeFalse)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 				dash := GetDashboardShouldReturn200(sc)
 
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should not be able to edit or save dashboard", func() {
 				Convey("Should not be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 			})
 
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -461,6 +768,37 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 			})
 		})
 		})
 	})
 	})
+
+	Convey("Given two dashboards with the same title in different folders", t, func() {
+		dashOne := m.NewDashboard("dash")
+		dashOne.Id = 2
+		dashOne.FolderId = 1
+		dashOne.HasAcl = false
+
+		dashTwo := m.NewDashboard("dash")
+		dashTwo.Id = 4
+		dashTwo.FolderId = 3
+		dashTwo.HasAcl = false
+
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{dashOne, dashTwo}
+			query.Result = dashboards
+			return nil
+		})
+
+		role := m.ROLE_EDITOR
+
+		loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+			CallDeleteDashboard(sc)
+
+			Convey("Should result in 412 Precondition failed", func() {
+				So(sc.resp.Code, ShouldEqual, 412)
+				result := sc.ToJson()
+				So(result.Get("status").MustString(), ShouldEqual, "multiple-slugs-exists")
+				So(result.Get("message").MustString(), ShouldEqual, m.ErrDashboardsWithSameSlugExists.Error())
+			})
+		})
+	})
 }
 }
 
 
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
@@ -509,6 +847,15 @@ func CallDeleteDashboard(sc *scenarioContext) {
 	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
 	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
 }
 }
 
 
+func CallDeleteDashboardByUid(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error {
+		return nil
+	})
+
+	sc.handlerFunc = DeleteDashboardByUid
+	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+}
+
 func CallPostDashboard(sc *scenarioContext) {
 func CallPostDashboard(sc *scenarioContext) {
 	bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
 	bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
 		return nil
 		return nil
@@ -526,24 +873,23 @@ func CallPostDashboard(sc *scenarioContext) {
 	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
 	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
 }
 }
 
 
+func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) {
+	CallPostDashboard(sc)
+
+	So(sc.resp.Code, ShouldEqual, 200)
+	result := sc.ToJson()
+	So(result.Get("status").MustString(), ShouldEqual, "success")
+	So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
+	So(result.Get("uid").MustString(), ShouldNotBeNil)
+	So(result.Get("slug").MustString(), ShouldNotBeNil)
+	So(result.Get("url").MustString(), ShouldNotBeNil)
+}
+
 func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) {
 func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) {
 	Convey(desc+" "+url, func() {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
-
+		sc := setupScenarioContext(url)
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 			sc.context = c
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.UserId = TestUserID
@@ -561,3 +907,10 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
 		fn(sc)
 		fn(sc)
 	})
 	})
 }
 }
+
+func (sc *scenarioContext) ToJson() *simplejson.Json {
+	var result *simplejson.Json
+	err := json.NewDecoder(sc.resp.Body).Decode(&result)
+	So(err, ShouldBeNil)
+	return result
+}

+ 0 - 91
pkg/api/datasources_test.go

@@ -2,17 +2,11 @@ package api
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"path/filepath"
 	"testing"
 	"testing"
 
 
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/models"
-	macaron "gopkg.in/macaron.v1"
 
 
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/middleware"
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
 
 
@@ -54,88 +48,3 @@ func TestDataSourcesProxy(t *testing.T) {
 		})
 		})
 	})
 	})
 }
 }
-
-func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
-	loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn)
-}
-
-func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) {
-	Convey(desc+" "+url, func() {
-		defer bus.ClearBusHandlers()
-
-		sc := &scenarioContext{
-			url: url,
-		}
-		viewsPath, _ := filepath.Abs("../../public/views")
-
-		sc.m = macaron.New()
-		sc.m.Use(macaron.Renderer(macaron.RenderOptions{
-			Directory: viewsPath,
-			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
-		}))
-
-		sc.m.Use(middleware.GetContextHandler())
-		sc.m.Use(middleware.Sessioner(&session.Options{}))
-
-		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
-			sc.context = c
-			sc.context.UserId = TestUserID
-			sc.context.OrgId = TestOrgID
-			sc.context.OrgRole = role
-			if sc.handlerFunc != nil {
-				return sc.handlerFunc(sc.context)
-			}
-
-			return nil
-		})
-
-		switch method {
-		case "GET":
-			sc.m.Get(routePattern, sc.defaultHandler)
-		case "DELETE":
-			sc.m.Delete(routePattern, sc.defaultHandler)
-		}
-
-		fn(sc)
-	})
-}
-
-func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
-	sc.resp = httptest.NewRecorder()
-	req, err := http.NewRequest(method, url, nil)
-	So(err, ShouldBeNil)
-	sc.req = req
-
-	return sc
-}
-
-func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
-	sc.resp = httptest.NewRecorder()
-	req, err := http.NewRequest(method, url, nil)
-	q := req.URL.Query()
-	for k, v := range queryParams {
-		q.Add(k, v)
-	}
-	req.URL.RawQuery = q.Encode()
-	So(err, ShouldBeNil)
-	sc.req = req
-
-	return sc
-}
-
-type scenarioContext struct {
-	m              *macaron.Macaron
-	context        *middleware.Context
-	resp           *httptest.ResponseRecorder
-	handlerFunc    handlerFunc
-	defaultHandler macaron.Handler
-	req            *http.Request
-	url            string
-}
-
-func (sc *scenarioContext) exec() {
-	sc.m.ServeHTTP(sc.resp, sc.req)
-}
-
-type scenarioFunc func(c *scenarioContext)
-type handlerFunc func(c *middleware.Context) Response

+ 1 - 0
pkg/api/dtos/alerting.go

@@ -20,6 +20,7 @@ type AlertRule struct {
 	EvalData       *simplejson.Json `json:"evalData"`
 	EvalData       *simplejson.Json `json:"evalData"`
 	ExecutionError string           `json:"executionError"`
 	ExecutionError string           `json:"executionError"`
 	DashbboardUri  string           `json:"dashboardUri"`
 	DashbboardUri  string           `json:"dashboardUri"`
+	CanEdit        bool             `json:"canEdit"`
 }
 }
 
 
 type AlertNotification struct {
 type AlertNotification struct {

+ 1 - 0
pkg/api/dtos/dashboard.go

@@ -16,6 +16,7 @@ type DashboardMeta struct {
 	CanAdmin    bool      `json:"canAdmin"`
 	CanAdmin    bool      `json:"canAdmin"`
 	CanStar     bool      `json:"canStar"`
 	CanStar     bool      `json:"canStar"`
 	Slug        string    `json:"slug"`
 	Slug        string    `json:"slug"`
+	Url         string    `json:"url"`
 	Expires     time.Time `json:"expires"`
 	Expires     time.Time `json:"expires"`
 	Created     time.Time `json:"created"`
 	Created     time.Time `json:"created"`
 	Updated     time.Time `json:"updated"`
 	Updated     time.Time `json:"updated"`

+ 8 - 4
pkg/api/folders.go

@@ -37,9 +37,9 @@ func folderGuardianResponse(err error) Response {
 	return ApiError(403, "Access denied to this folder", nil)
 	return ApiError(403, "Access denied to this folder", nil)
 }
 }
 
 
-func GetFolders(c *middleware.Context) Response {
+func GetFoldersForSignedInUser(c *middleware.Context) Response {
 	title := c.Query("query")
 	title := c.Query("query")
-	query := m.GetFoldersQuery{
+	query := m.GetFoldersForSignedInUserQuery{
 		OrgId:        c.OrgId,
 		OrgId:        c.OrgId,
 		SignedInUser: c.SignedInUser,
 		SignedInUser: c.SignedInUser,
 		Title:        title,
 		Title:        title,
@@ -205,8 +205,8 @@ func toFolderError(err error) Response {
 		return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil)
 		return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil)
 	}
 	}
 
 
-	if err == m.ErrDashboardWithSameNameExists {
-		return Json(412, util.DynMap{"status": "name-exists", "message": m.ErrFolderWithSameNameExists.Error()})
+	if err == m.ErrDashboardWithSameUIDExists {
+		return Json(412, util.DynMap{"status": "uid-exists", "message": m.ErrFolderWithSameUIDExists.Error()})
 	}
 	}
 
 
 	if err == m.ErrDashboardVersionMismatch {
 	if err == m.ErrDashboardVersionMismatch {
@@ -217,5 +217,9 @@ func toFolderError(err error) Response {
 		return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()})
 		return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()})
 	}
 	}
 
 
+	if err == m.ErrDashboardFailedGenerateUniqueUid {
+		err = m.ErrFolderFailedGenerateUniqueUid
+	}
+
 	return ApiError(500, "Failed to create folder", err)
 	return ApiError(500, "Failed to create folder", err)
 }
 }

+ 4 - 3
pkg/api/login.go

@@ -102,12 +102,13 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response {
 	}
 	}
 
 
 	authQuery := login.LoginUserQuery{
 	authQuery := login.LoginUserQuery{
-		Username: cmd.User,
-		Password: cmd.Password,
+		Username:  cmd.User,
+		Password:  cmd.Password,
+		IpAddress: c.Req.RemoteAddr,
 	}
 	}
 
 
 	if err := bus.Dispatch(&authQuery); err != nil {
 	if err := bus.Dispatch(&authQuery); err != nil {
-		if err == login.ErrInvalidCredentials {
+		if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts {
 			return ApiError(401, "Invalid username or password", err)
 			return ApiError(401, "Invalid username or password", err)
 		}
 		}
 
 

+ 12 - 7
pkg/components/renderer/renderer.go

@@ -91,9 +91,15 @@ func RenderToPng(params *RenderOpts) (string, error) {
 		timeout = 15
 		timeout = 15
 	}
 	}
 
 
+	phantomDebugArg := "--debug=false"
+	if log.GetLogLevelFor("png-renderer") >= log.LvlDebug {
+		phantomDebugArg = "--debug=true"
+	}
+
 	cmdArgs := []string{
 	cmdArgs := []string{
 		"--ignore-ssl-errors=true",
 		"--ignore-ssl-errors=true",
 		"--web-security=false",
 		"--web-security=false",
+		phantomDebugArg,
 		scriptPath,
 		scriptPath,
 		"url=" + url,
 		"url=" + url,
 		"width=" + params.Width,
 		"width=" + params.Width,
@@ -109,15 +115,13 @@ func RenderToPng(params *RenderOpts) (string, error) {
 	}
 	}
 
 
 	cmd := exec.Command(binPath, cmdArgs...)
 	cmd := exec.Command(binPath, cmdArgs...)
-	stdout, err := cmd.StdoutPipe()
+	output, err := cmd.StdoutPipe()
 
 
 	if err != nil {
 	if err != nil {
+		rendererLog.Error("Could not acquire stdout pipe", err)
 		return "", err
 		return "", err
 	}
 	}
-	stderr, err := cmd.StderrPipe()
-	if err != nil {
-		return "", err
-	}
+	cmd.Stderr = cmd.Stdout
 
 
 	if params.Timezone != "" {
 	if params.Timezone != "" {
 		baseEnviron := os.Environ()
 		baseEnviron := os.Environ()
@@ -126,11 +130,12 @@ func RenderToPng(params *RenderOpts) (string, error) {
 
 
 	err = cmd.Start()
 	err = cmd.Start()
 	if err != nil {
 	if err != nil {
+		rendererLog.Error("Could not start command", err)
 		return "", err
 		return "", err
 	}
 	}
 
 
-	go io.Copy(os.Stdout, stdout)
-	go io.Copy(os.Stdout, stderr)
+	logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ")
+	go io.Copy(logWriter, output)
 
 
 	done := make(chan error)
 	done := make(chan error)
 	go func() {
 	go func() {

+ 24 - 4
pkg/log/log.go

@@ -21,6 +21,7 @@ import (
 
 
 var Root log15.Logger
 var Root log15.Logger
 var loggersToClose []DisposableHandler
 var loggersToClose []DisposableHandler
+var filters map[string]log15.Lvl
 
 
 func init() {
 func init() {
 	loggersToClose = make([]DisposableHandler, 0)
 	loggersToClose = make([]DisposableHandler, 0)
@@ -114,6 +115,25 @@ func Close() {
 	loggersToClose = make([]DisposableHandler, 0)
 	loggersToClose = make([]DisposableHandler, 0)
 }
 }
 
 
+func GetLogLevelFor(name string) Lvl {
+	if level, ok := filters[name]; ok {
+		switch level {
+		case log15.LvlWarn:
+			return LvlWarn
+		case log15.LvlInfo:
+			return LvlInfo
+		case log15.LvlError:
+			return LvlError
+		case log15.LvlCrit:
+			return LvlCrit
+		default:
+			return LvlDebug
+		}
+	}
+
+	return LvlInfo
+}
+
 var logLevels = map[string]log15.Lvl{
 var logLevels = map[string]log15.Lvl{
 	"trace":    log15.LvlDebug,
 	"trace":    log15.LvlDebug,
 	"debug":    log15.LvlDebug,
 	"debug":    log15.LvlDebug,
@@ -187,7 +207,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 
 
 		// Log level.
 		// Log level.
 		_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
 		_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
-		modeFilters := getFilters(util.SplitString(sec.Key("filters").String()))
+		filters := getFilters(util.SplitString(sec.Key("filters").String()))
 		format := getLogFormat(sec.Key("format").MustString(""))
 		format := getLogFormat(sec.Key("format").MustString(""))
 
 
 		var handler log15.Handler
 		var handler log15.Handler
@@ -219,12 +239,12 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 		}
 		}
 
 
 		for key, value := range defaultFilters {
 		for key, value := range defaultFilters {
-			if _, exist := modeFilters[key]; !exist {
-				modeFilters[key] = value
+			if _, exist := filters[key]; !exist {
+				filters[key] = value
 			}
 			}
 		}
 		}
 
 
-		handler = LogFilterHandler(level, modeFilters, handler)
+		handler = LogFilterHandler(level, filters, handler)
 		handlers = append(handlers, handler)
 		handlers = append(handlers, handler)
 	}
 	}
 
 

+ 39 - 0
pkg/log/log_writer.go

@@ -0,0 +1,39 @@
+package log
+
+import (
+	"io"
+	"strings"
+)
+
+type logWriterImpl struct {
+	log    Logger
+	level  Lvl
+	prefix string
+}
+
+func NewLogWriter(log Logger, level Lvl, prefix string) io.Writer {
+	return &logWriterImpl{
+		log:    log,
+		level:  level,
+		prefix: prefix,
+	}
+}
+
+func (l *logWriterImpl) Write(p []byte) (n int, err error) {
+	message := l.prefix + strings.TrimSpace(string(p))
+
+	switch l.level {
+	case LvlCrit:
+		l.log.Crit(message)
+	case LvlError:
+		l.log.Error(message)
+	case LvlWarn:
+		l.log.Warn(message)
+	case LvlInfo:
+		l.log.Info(message)
+	default:
+		l.log.Debug(message)
+	}
+
+	return len(p), nil
+}

+ 116 - 0
pkg/log/log_writer_test.go

@@ -0,0 +1,116 @@
+package log
+
+import (
+	"testing"
+
+	"github.com/inconshreveable/log15"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+type FakeLogger struct {
+	debug string
+	info  string
+	warn  string
+	err   string
+	crit  string
+}
+
+func (f *FakeLogger) New(ctx ...interface{}) log15.Logger {
+	return nil
+}
+
+func (f *FakeLogger) Debug(msg string, ctx ...interface{}) {
+	f.debug = msg
+}
+
+func (f *FakeLogger) Info(msg string, ctx ...interface{}) {
+	f.info = msg
+}
+
+func (f *FakeLogger) Warn(msg string, ctx ...interface{}) {
+	f.warn = msg
+}
+
+func (f *FakeLogger) Error(msg string, ctx ...interface{}) {
+	f.err = msg
+}
+
+func (f *FakeLogger) Crit(msg string, ctx ...interface{}) {
+	f.crit = msg
+}
+
+func (f *FakeLogger) GetHandler() log15.Handler {
+	return nil
+}
+
+func (f *FakeLogger) SetHandler(l log15.Handler) {}
+
+func TestLogWriter(t *testing.T) {
+	Convey("When writing to a LogWriter", t, func() {
+		Convey("Should write using the correct level [crit]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlCrit, "")
+			n, err := crit.Write([]byte("crit"))
+
+			So(n, ShouldEqual, 4)
+			So(err, ShouldBeNil)
+			So(fake.crit, ShouldEqual, "crit")
+		})
+
+		Convey("Should write using the correct level [error]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlError, "")
+			n, err := crit.Write([]byte("error"))
+
+			So(n, ShouldEqual, 5)
+			So(err, ShouldBeNil)
+			So(fake.err, ShouldEqual, "error")
+		})
+
+		Convey("Should write using the correct level [warn]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlWarn, "")
+			n, err := crit.Write([]byte("warn"))
+
+			So(n, ShouldEqual, 4)
+			So(err, ShouldBeNil)
+			So(fake.warn, ShouldEqual, "warn")
+		})
+
+		Convey("Should write using the correct level [info]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlInfo, "")
+			n, err := crit.Write([]byte("info"))
+
+			So(n, ShouldEqual, 4)
+			So(err, ShouldBeNil)
+			So(fake.info, ShouldEqual, "info")
+		})
+
+		Convey("Should write using the correct level [debug]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlDebug, "")
+			n, err := crit.Write([]byte("debug"))
+
+			So(n, ShouldEqual, 5)
+			So(err, ShouldBeNil)
+			So(fake.debug, ShouldEqual, "debug")
+		})
+
+		Convey("Should prefix the output with the prefix", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlDebug, "prefix")
+			n, err := crit.Write([]byte("debug"))
+
+			So(n, ShouldEqual, 5) // n is how much of input consumed
+			So(err, ShouldBeNil)
+			So(fake.debug, ShouldEqual, "prefixdebug")
+		})
+	})
+}

+ 21 - 32
pkg/login/auth.go

@@ -3,21 +3,20 @@ package login
 import (
 import (
 	"errors"
 	"errors"
 
 
-	"crypto/subtle"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/setting"
-	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 var (
 var (
-	ErrInvalidCredentials = errors.New("Invalid Username or Password")
+	ErrInvalidCredentials   = errors.New("Invalid Username or Password")
+	ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked")
 )
 )
 
 
 type LoginUserQuery struct {
 type LoginUserQuery struct {
-	Username string
-	Password string
-	User     *m.User
+	Username  string
+	Password  string
+	User      *m.User
+	IpAddress string
 }
 }
 
 
 func Init() {
 func Init() {
@@ -26,41 +25,31 @@ func Init() {
 }
 }
 
 
 func AuthenticateUser(query *LoginUserQuery) error {
 func AuthenticateUser(query *LoginUserQuery) error {
-	err := loginUsingGrafanaDB(query)
-	if err == nil || err != ErrInvalidCredentials {
+	if err := validateLoginAttempts(query.Username); err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	if setting.LdapEnabled {
-		for _, server := range LdapCfg.Servers {
-			author := NewLdapAuthenticator(server)
-			err = author.Login(query)
-			if err == nil || err != ErrInvalidCredentials {
-				return err
-			}
-		}
+	err := loginUsingGrafanaDB(query)
+	if err == nil || (err != m.ErrUserNotFound && err != ErrInvalidCredentials) {
+		return err
 	}
 	}
 
 
-	return err
-}
-
-func loginUsingGrafanaDB(query *LoginUserQuery) error {
-	userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username}
-
-	if err := bus.Dispatch(&userQuery); err != nil {
-		if err == m.ErrUserNotFound {
-			return ErrInvalidCredentials
+	ldapEnabled, ldapErr := loginUsingLdap(query)
+	if ldapEnabled {
+		if ldapErr == nil || ldapErr != ErrInvalidCredentials {
+			return ldapErr
 		}
 		}
-		return err
+
+		err = ldapErr
 	}
 	}
 
 
-	user := userQuery.Result
+	if err == ErrInvalidCredentials {
+		saveInvalidLoginAttempt(query)
+	}
 
 
-	passwordHashed := util.EncodePassword(query.Password, user.Salt)
-	if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(user.Password)) != 1 {
+	if err == m.ErrUserNotFound {
 		return ErrInvalidCredentials
 		return ErrInvalidCredentials
 	}
 	}
 
 
-	query.User = user
-	return nil
+	return err
 }
 }

+ 214 - 0
pkg/login/auth_test.go

@@ -0,0 +1,214 @@
+package login
+
+import (
+	"errors"
+	"testing"
+
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAuthenticateUser(t *testing.T) {
+	Convey("Authenticate user", t, func() {
+		authScenario("When a user authenticates having too many login attempts", func(sc *authScenarioContext) {
+			mockLoginAttemptValidation(ErrTooManyLoginAttempts, sc)
+			mockLoginUsingGrafanaDB(nil, sc)
+			mockLoginUsingLdap(true, nil, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, ErrTooManyLoginAttempts)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeFalse)
+				So(sc.ldapLoginWasCalled, ShouldBeFalse)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse)
+			})
+		})
+
+		authScenario("When grafana user authenticate with valid credentials", func(sc *authScenarioContext) {
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(nil, sc)
+			mockLoginUsingLdap(true, ErrInvalidCredentials, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, nil)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeFalse)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse)
+			})
+		})
+
+		authScenario("When grafana user authenticate and unexpected error occurs", func(sc *authScenarioContext) {
+			customErr := errors.New("custom")
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(customErr, sc)
+			mockLoginUsingLdap(true, ErrInvalidCredentials, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, customErr)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeFalse)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse)
+			})
+		})
+
+		authScenario("When a non-existing grafana user authenticate and ldap disabled", func(sc *authScenarioContext) {
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc)
+			mockLoginUsingLdap(false, nil, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, ErrInvalidCredentials)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeTrue)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse)
+			})
+		})
+
+		authScenario("When a non-existing grafana user authenticate and invalid ldap credentials", func(sc *authScenarioContext) {
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc)
+			mockLoginUsingLdap(true, ErrInvalidCredentials, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, ErrInvalidCredentials)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeTrue)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue)
+			})
+		})
+
+		authScenario("When a non-existing grafana user authenticate and valid ldap credentials", func(sc *authScenarioContext) {
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc)
+			mockLoginUsingLdap(true, nil, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldBeNil)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeTrue)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse)
+			})
+		})
+
+		authScenario("When a non-existing grafana user authenticate and ldap returns unexpected error", func(sc *authScenarioContext) {
+			customErr := errors.New("custom")
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc)
+			mockLoginUsingLdap(true, customErr, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, customErr)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeTrue)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse)
+			})
+		})
+
+		authScenario("When grafana user authenticate with invalid credentials and invalid ldap credentials", func(sc *authScenarioContext) {
+			mockLoginAttemptValidation(nil, sc)
+			mockLoginUsingGrafanaDB(ErrInvalidCredentials, sc)
+			mockLoginUsingLdap(true, ErrInvalidCredentials, sc)
+			mockSaveInvalidLoginAttempt(sc)
+
+			err := AuthenticateUser(sc.loginUserQuery)
+
+			Convey("it should result in", func() {
+				So(err, ShouldEqual, ErrInvalidCredentials)
+				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue)
+				So(sc.grafanaLoginWasCalled, ShouldBeTrue)
+				So(sc.ldapLoginWasCalled, ShouldBeTrue)
+				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue)
+			})
+		})
+	})
+}
+
+type authScenarioContext struct {
+	loginUserQuery                   *LoginUserQuery
+	grafanaLoginWasCalled            bool
+	ldapLoginWasCalled               bool
+	loginAttemptValidationWasCalled  bool
+	saveInvalidLoginAttemptWasCalled bool
+}
+
+type authScenarioFunc func(sc *authScenarioContext)
+
+func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) {
+	loginUsingGrafanaDB = func(query *LoginUserQuery) error {
+		sc.grafanaLoginWasCalled = true
+		return err
+	}
+}
+
+func mockLoginUsingLdap(enabled bool, err error, sc *authScenarioContext) {
+	loginUsingLdap = func(query *LoginUserQuery) (bool, error) {
+		sc.ldapLoginWasCalled = true
+		return enabled, err
+	}
+}
+
+func mockLoginAttemptValidation(err error, sc *authScenarioContext) {
+	validateLoginAttempts = func(username string) error {
+		sc.loginAttemptValidationWasCalled = true
+		return err
+	}
+}
+
+func mockSaveInvalidLoginAttempt(sc *authScenarioContext) {
+	saveInvalidLoginAttempt = func(query *LoginUserQuery) {
+		sc.saveInvalidLoginAttemptWasCalled = true
+	}
+}
+
+func authScenario(desc string, fn authScenarioFunc) {
+	Convey(desc, func() {
+		origLoginUsingGrafanaDB := loginUsingGrafanaDB
+		origLoginUsingLdap := loginUsingLdap
+		origValidateLoginAttempts := validateLoginAttempts
+		origSaveInvalidLoginAttempt := saveInvalidLoginAttempt
+
+		sc := &authScenarioContext{
+			loginUserQuery: &LoginUserQuery{
+				Username:  "user",
+				Password:  "pwd",
+				IpAddress: "192.168.1.1:56433",
+			},
+		}
+
+		defer func() {
+			loginUsingGrafanaDB = origLoginUsingGrafanaDB
+			loginUsingLdap = origLoginUsingLdap
+			validateLoginAttempts = origValidateLoginAttempts
+			saveInvalidLoginAttempt = origSaveInvalidLoginAttempt
+		}()
+
+		fn(sc)
+	})
+}

+ 48 - 0
pkg/login/brute_force_login_protection.go

@@ -0,0 +1,48 @@
+package login
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+var (
+	maxInvalidLoginAttempts int64         = 5
+	loginAttemptsWindow     time.Duration = time.Minute * 5
+)
+
+var validateLoginAttempts = func(username string) error {
+	if setting.DisableBruteForceLoginProtection {
+		return nil
+	}
+
+	loginAttemptCountQuery := m.GetUserLoginAttemptCountQuery{
+		Username: username,
+		Since:    time.Now().Add(-loginAttemptsWindow),
+	}
+
+	if err := bus.Dispatch(&loginAttemptCountQuery); err != nil {
+		return err
+	}
+
+	if loginAttemptCountQuery.Result >= maxInvalidLoginAttempts {
+		return ErrTooManyLoginAttempts
+	}
+
+	return nil
+}
+
+var saveInvalidLoginAttempt = func(query *LoginUserQuery) {
+	if setting.DisableBruteForceLoginProtection {
+		return
+	}
+
+	loginAttemptCommand := m.CreateLoginAttemptCommand{
+		Username:  query.Username,
+		IpAddress: query.IpAddress,
+	}
+
+	bus.Dispatch(&loginAttemptCommand)
+}

+ 125 - 0
pkg/login/brute_force_login_protection_test.go

@@ -0,0 +1,125 @@
+package login
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestLoginAttemptsValidation(t *testing.T) {
+	Convey("Validate login attempts", t, func() {
+		Convey("Given brute force login protection enabled", func() {
+			setting.DisableBruteForceLoginProtection = false
+
+			Convey("When user login attempt count equals max-1 ", func() {
+				withLoginAttempts(maxInvalidLoginAttempts - 1)
+				err := validateLoginAttempts("user")
+
+				Convey("it should not result in error", func() {
+					So(err, ShouldBeNil)
+				})
+			})
+
+			Convey("When user login attempt count equals max ", func() {
+				withLoginAttempts(maxInvalidLoginAttempts)
+				err := validateLoginAttempts("user")
+
+				Convey("it should result in too many login attempts error", func() {
+					So(err, ShouldEqual, ErrTooManyLoginAttempts)
+				})
+			})
+
+			Convey("When user login attempt count is greater than max ", func() {
+				withLoginAttempts(maxInvalidLoginAttempts + 5)
+				err := validateLoginAttempts("user")
+
+				Convey("it should result in too many login attempts error", func() {
+					So(err, ShouldEqual, ErrTooManyLoginAttempts)
+				})
+			})
+
+			Convey("When saving invalid login attempt", func() {
+				defer bus.ClearBusHandlers()
+				createLoginAttemptCmd := &m.CreateLoginAttemptCommand{}
+
+				bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error {
+					createLoginAttemptCmd = cmd
+					return nil
+				})
+
+				saveInvalidLoginAttempt(&LoginUserQuery{
+					Username:  "user",
+					Password:  "pwd",
+					IpAddress: "192.168.1.1:56433",
+				})
+
+				Convey("it should dispatch command", func() {
+					So(createLoginAttemptCmd, ShouldNotBeNil)
+					So(createLoginAttemptCmd.Username, ShouldEqual, "user")
+					So(createLoginAttemptCmd.IpAddress, ShouldEqual, "192.168.1.1:56433")
+				})
+			})
+		})
+
+		Convey("Given brute force login protection disabled", func() {
+			setting.DisableBruteForceLoginProtection = true
+
+			Convey("When user login attempt count equals max-1 ", func() {
+				withLoginAttempts(maxInvalidLoginAttempts - 1)
+				err := validateLoginAttempts("user")
+
+				Convey("it should not result in error", func() {
+					So(err, ShouldBeNil)
+				})
+			})
+
+			Convey("When user login attempt count equals max ", func() {
+				withLoginAttempts(maxInvalidLoginAttempts)
+				err := validateLoginAttempts("user")
+
+				Convey("it should not result in error", func() {
+					So(err, ShouldBeNil)
+				})
+			})
+
+			Convey("When user login attempt count is greater than max ", func() {
+				withLoginAttempts(maxInvalidLoginAttempts + 5)
+				err := validateLoginAttempts("user")
+
+				Convey("it should not result in error", func() {
+					So(err, ShouldBeNil)
+				})
+			})
+
+			Convey("When saving invalid login attempt", func() {
+				defer bus.ClearBusHandlers()
+				createLoginAttemptCmd := (*m.CreateLoginAttemptCommand)(nil)
+
+				bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error {
+					createLoginAttemptCmd = cmd
+					return nil
+				})
+
+				saveInvalidLoginAttempt(&LoginUserQuery{
+					Username:  "user",
+					Password:  "pwd",
+					IpAddress: "192.168.1.1:56433",
+				})
+
+				Convey("it should not dispatch command", func() {
+					So(createLoginAttemptCmd, ShouldBeNil)
+				})
+			})
+		})
+	})
+}
+
+func withLoginAttempts(loginAttempts int64) {
+	bus.AddHandler("test", func(query *m.GetUserLoginAttemptCountQuery) error {
+		query.Result = loginAttempts
+		return nil
+	})
+}

+ 35 - 0
pkg/login/grafana_login.go

@@ -0,0 +1,35 @@
+package login
+
+import (
+	"crypto/subtle"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+var validatePassword = func(providedPassword string, userPassword string, userSalt string) error {
+	passwordHashed := util.EncodePassword(providedPassword, userSalt)
+	if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(userPassword)) != 1 {
+		return ErrInvalidCredentials
+	}
+
+	return nil
+}
+
+var loginUsingGrafanaDB = func(query *LoginUserQuery) error {
+	userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username}
+
+	if err := bus.Dispatch(&userQuery); err != nil {
+		return err
+	}
+
+	user := userQuery.Result
+
+	if err := validatePassword(query.Password, user.Password, user.Salt); err != nil {
+		return err
+	}
+
+	query.User = user
+	return nil
+}

+ 139 - 0
pkg/login/grafana_login_test.go

@@ -0,0 +1,139 @@
+package login
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestGrafanaLogin(t *testing.T) {
+	Convey("Login using Grafana DB", t, func() {
+		grafanaLoginScenario("When login with non-existing user", func(sc *grafanaLoginScenarioContext) {
+			sc.withNonExistingUser()
+			err := loginUsingGrafanaDB(sc.loginUserQuery)
+
+			Convey("it should result in user not found error", func() {
+				So(err, ShouldEqual, m.ErrUserNotFound)
+			})
+
+			Convey("it should not call password validation", func() {
+				So(sc.validatePasswordCalled, ShouldBeFalse)
+			})
+
+			Convey("it should not pupulate user object", func() {
+				So(sc.loginUserQuery.User, ShouldBeNil)
+			})
+		})
+
+		grafanaLoginScenario("When login with invalid credentials", func(sc *grafanaLoginScenarioContext) {
+			sc.withInvalidPassword()
+			err := loginUsingGrafanaDB(sc.loginUserQuery)
+
+			Convey("it should result in invalid credentials error", func() {
+				So(err, ShouldEqual, ErrInvalidCredentials)
+			})
+
+			Convey("it should call password validation", func() {
+				So(sc.validatePasswordCalled, ShouldBeTrue)
+			})
+
+			Convey("it should not pupulate user object", func() {
+				So(sc.loginUserQuery.User, ShouldBeNil)
+			})
+		})
+
+		grafanaLoginScenario("When login with valid credentials", func(sc *grafanaLoginScenarioContext) {
+			sc.withValidCredentials()
+			err := loginUsingGrafanaDB(sc.loginUserQuery)
+
+			Convey("it should not result in error", func() {
+				So(err, ShouldBeNil)
+			})
+
+			Convey("it should call password validation", func() {
+				So(sc.validatePasswordCalled, ShouldBeTrue)
+			})
+
+			Convey("it should pupulate user object", func() {
+				So(sc.loginUserQuery.User, ShouldNotBeNil)
+				So(sc.loginUserQuery.User.Login, ShouldEqual, sc.loginUserQuery.Username)
+				So(sc.loginUserQuery.User.Password, ShouldEqual, sc.loginUserQuery.Password)
+			})
+		})
+	})
+}
+
+type grafanaLoginScenarioContext struct {
+	loginUserQuery         *LoginUserQuery
+	validatePasswordCalled bool
+}
+
+type grafanaLoginScenarioFunc func(c *grafanaLoginScenarioContext)
+
+func grafanaLoginScenario(desc string, fn grafanaLoginScenarioFunc) {
+	Convey(desc, func() {
+		origValidatePassword := validatePassword
+
+		sc := &grafanaLoginScenarioContext{
+			loginUserQuery: &LoginUserQuery{
+				Username:  "user",
+				Password:  "pwd",
+				IpAddress: "192.168.1.1:56433",
+			},
+			validatePasswordCalled: false,
+		}
+
+		defer func() {
+			validatePassword = origValidatePassword
+		}()
+
+		fn(sc)
+	})
+}
+
+func mockPasswordValidation(valid bool, sc *grafanaLoginScenarioContext) {
+	validatePassword = func(providedPassword string, userPassword string, userSalt string) error {
+		sc.validatePasswordCalled = true
+
+		if !valid {
+			return ErrInvalidCredentials
+		}
+
+		return nil
+	}
+}
+
+func (sc *grafanaLoginScenarioContext) getUserByLoginQueryReturns(user *m.User) {
+	bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error {
+		if user == nil {
+			return m.ErrUserNotFound
+		}
+
+		query.Result = user
+		return nil
+	})
+}
+
+func (sc *grafanaLoginScenarioContext) withValidCredentials() {
+	sc.getUserByLoginQueryReturns(&m.User{
+		Id:       1,
+		Login:    sc.loginUserQuery.Username,
+		Password: sc.loginUserQuery.Password,
+		Salt:     "salt",
+	})
+	mockPasswordValidation(true, sc)
+}
+
+func (sc *grafanaLoginScenarioContext) withNonExistingUser() {
+	sc.getUserByLoginQueryReturns(nil)
+}
+
+func (sc *grafanaLoginScenarioContext) withInvalidPassword() {
+	sc.getUserByLoginQueryReturns(&m.User{
+		Password: sc.loginUserQuery.Password,
+		Salt:     "salt",
+	})
+	mockPasswordValidation(false, sc)
+}

+ 21 - 0
pkg/login/ldap_login.go

@@ -0,0 +1,21 @@
+package login
+
+import (
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+var loginUsingLdap = func(query *LoginUserQuery) (bool, error) {
+	if !setting.LdapEnabled {
+		return false, nil
+	}
+
+	for _, server := range LdapCfg.Servers {
+		author := NewLdapAuthenticator(server)
+		err := author.Login(query)
+		if err == nil || err != ErrInvalidCredentials {
+			return true, err
+		}
+	}
+
+	return true, ErrInvalidCredentials
+}

+ 172 - 0
pkg/login/ldap_login_test.go

@@ -0,0 +1,172 @@
+package login
+
+import (
+	"testing"
+
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestLdapLogin(t *testing.T) {
+	Convey("Login using ldap", t, func() {
+		Convey("Given ldap enabled and a server configured", func() {
+			setting.LdapEnabled = true
+			LdapCfg.Servers = append(LdapCfg.Servers,
+				&LdapServerConf{
+					Host: "",
+				})
+
+			ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) {
+				sc.withLoginResult(false)
+				enabled, err := loginUsingLdap(sc.loginUserQuery)
+
+				Convey("it should return true", func() {
+					So(enabled, ShouldBeTrue)
+				})
+
+				Convey("it should return invalid credentials error", func() {
+					So(err, ShouldEqual, ErrInvalidCredentials)
+				})
+
+				Convey("it should call ldap login", func() {
+					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue)
+				})
+			})
+
+			ldapLoginScenario("When login with valid credentials", func(sc *ldapLoginScenarioContext) {
+				sc.withLoginResult(true)
+				enabled, err := loginUsingLdap(sc.loginUserQuery)
+
+				Convey("it should return true", func() {
+					So(enabled, ShouldBeTrue)
+				})
+
+				Convey("it should not return error", func() {
+					So(err, ShouldBeNil)
+				})
+
+				Convey("it should call ldap login", func() {
+					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue)
+				})
+			})
+		})
+
+		Convey("Given ldap enabled and no server configured", func() {
+			setting.LdapEnabled = true
+			LdapCfg.Servers = make([]*LdapServerConf, 0)
+
+			ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) {
+				sc.withLoginResult(true)
+				enabled, err := loginUsingLdap(sc.loginUserQuery)
+
+				Convey("it should return true", func() {
+					So(enabled, ShouldBeTrue)
+				})
+
+				Convey("it should return invalid credentials error", func() {
+					So(err, ShouldEqual, ErrInvalidCredentials)
+				})
+
+				Convey("it should not call ldap login", func() {
+					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse)
+				})
+			})
+		})
+
+		Convey("Given ldap disabled", func() {
+			setting.LdapEnabled = false
+
+			ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) {
+				sc.withLoginResult(false)
+				enabled, err := loginUsingLdap(&LoginUserQuery{
+					Username: "user",
+					Password: "pwd",
+				})
+
+				Convey("it should return false", func() {
+					So(enabled, ShouldBeFalse)
+				})
+
+				Convey("it should not return error", func() {
+					So(err, ShouldBeNil)
+				})
+
+				Convey("it should not call ldap login", func() {
+					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse)
+				})
+			})
+		})
+	})
+}
+
+func mockLdapAuthenticator(valid bool) *mockLdapAuther {
+	mock := &mockLdapAuther{
+		validLogin: valid,
+	}
+
+	NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther {
+		return mock
+	}
+
+	return mock
+}
+
+type mockLdapAuther struct {
+	validLogin  bool
+	loginCalled bool
+}
+
+func (a *mockLdapAuther) Login(query *LoginUserQuery) error {
+	a.loginCalled = true
+
+	if !a.validLogin {
+		return ErrInvalidCredentials
+	}
+
+	return nil
+}
+
+func (a *mockLdapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error {
+	return nil
+}
+
+func (a *mockLdapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) {
+	return nil, nil
+}
+
+func (a *mockLdapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error {
+	return nil
+}
+
+type ldapLoginScenarioContext struct {
+	loginUserQuery        *LoginUserQuery
+	ldapAuthenticatorMock *mockLdapAuther
+}
+
+type ldapLoginScenarioFunc func(c *ldapLoginScenarioContext)
+
+func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) {
+	Convey(desc, func() {
+		origNewLdapAuthenticator := NewLdapAuthenticator
+
+		sc := &ldapLoginScenarioContext{
+			loginUserQuery: &LoginUserQuery{
+				Username:  "user",
+				Password:  "pwd",
+				IpAddress: "192.168.1.1:56433",
+			},
+			ldapAuthenticatorMock: &mockLdapAuther{},
+		}
+
+		defer func() {
+			NewLdapAuthenticator = origNewLdapAuthenticator
+		}()
+
+		fn(sc)
+	})
+}
+
+func (sc *ldapLoginScenarioContext) withLoginResult(valid bool) {
+	sc.ldapAuthenticatorMock = mockLdapAuthenticator(valid)
+}

+ 0 - 0
pkg/login/settings.go → pkg/login/ldap_settings.go


+ 46 - 0
pkg/middleware/dashboard_redirect.go

@@ -0,0 +1,46 @@
+package middleware
+
+import (
+	"strings"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"gopkg.in/macaron.v1"
+)
+
+func getDashboardUrlBySlug(orgId int64, slug string) (string, error) {
+	query := m.GetDashboardQuery{Slug: slug, OrgId: orgId}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return "", m.ErrDashboardNotFound
+	}
+
+	return m.GetDashboardUrl(query.Result.Uid, query.Result.Slug), nil
+}
+
+func RedirectFromLegacyDashboardUrl() macaron.Handler {
+	return func(c *Context) {
+		slug := c.Params("slug")
+
+		if slug != "" {
+			if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
+				c.Redirect(url, 301)
+				return
+			}
+		}
+	}
+}
+
+func RedirectFromLegacyDashboardSoloUrl() macaron.Handler {
+	return func(c *Context) {
+		slug := c.Params("slug")
+
+		if slug != "" {
+			if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
+				url = strings.Replace(url, "/d/", "/d-solo/", 1)
+				c.Redirect(url, 301)
+				return
+			}
+		}
+	}
+}

+ 56 - 0
pkg/middleware/dashboard_redirect_test.go

@@ -0,0 +1,56 @@
+package middleware
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestMiddlewareDashboardRedirect(t *testing.T) {
+	Convey("Given the dashboard redirect middleware", t, func() {
+		bus.ClearBusHandlers()
+		redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardUrl()
+		redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloUrl()
+
+		fakeDash := m.NewDashboard("Child dash")
+		fakeDash.Id = 1
+		fakeDash.FolderId = 1
+		fakeDash.HasAcl = false
+		fakeDash.Uid = util.GenerateShortUid()
+
+		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+			query.Result = fakeDash
+			return nil
+		})
+
+		middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) {
+			sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
+
+			sc.fakeReqWithParams("GET", "/dashboard/db/dash", map[string]string{}).exec()
+
+			Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
+				So(sc.resp.Code, ShouldEqual, 301)
+				redirectUrl, _ := sc.resp.Result().Location()
+				So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug))
+			})
+		})
+
+		middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) {
+			sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
+
+			sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash", map[string]string{}).exec()
+
+			Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
+				So(sc.resp.Code, ShouldEqual, 301)
+				redirectUrl, _ := sc.resp.Result().Location()
+				expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
+				expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1)
+				So(redirectUrl.Path, ShouldEqual, expectedUrl)
+			})
+		})
+	})
+}

+ 14 - 0
pkg/middleware/middleware_test.go

@@ -399,6 +399,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
 	return sc
 	return sc
 }
 }
 
 
+func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	q := req.URL.Query()
+	for k, v := range queryParams {
+		q.Add(k, v)
+	}
+	req.URL.RawQuery = q.Encode()
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
 func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
 func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
 	sc.handlerFunc = fn
 	sc.handlerFunc = fn
 	return sc
 	return sc

+ 0 - 4
pkg/models/alert.go

@@ -159,10 +159,6 @@ type SetAlertStateCommand struct {
 	Timestamp time.Time
 	Timestamp time.Time
 }
 }
 
 
-type DeleteAlertCommand struct {
-	AlertId int64
-}
-
 //Queries
 //Queries
 type GetAlertsQuery struct {
 type GetAlertsQuery struct {
 	OrgId       int64
 	OrgId       int64

+ 85 - 9
pkg/models/dashboards.go

@@ -2,23 +2,28 @@ package models
 
 
 import (
 import (
 	"errors"
 	"errors"
+	"fmt"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
 	"github.com/gosimple/slug"
 	"github.com/gosimple/slug"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
 // Typed errors
 // Typed errors
 var (
 var (
-	ErrDashboardNotFound                 = errors.New("Dashboard not found")
-	ErrDashboardSnapshotNotFound         = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameNameExists       = errors.New("A dashboard with the same name already exists")
-	ErrDashboardVersionMismatch          = errors.New("The dashboard has been changed by someone else")
-	ErrDashboardTitleEmpty               = errors.New("Dashboard title cannot be empty")
-	ErrDashboardFolderCannotHaveParent   = errors.New("A Dashboard Folder cannot be added to another folder")
-	ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
-	ErrDashboardFailedToUpdateAlertData  = errors.New("Failed to save alert data")
+	ErrDashboardNotFound                   = errors.New("Dashboard 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")
+	ErrDashboardVersionMismatch            = errors.New("The dashboard has been changed by someone else")
+	ErrDashboardTitleEmpty                 = errors.New("Dashboard title cannot be empty")
+	ErrDashboardFolderCannotHaveParent     = errors.New("A Dashboard Folder cannot be added to another folder")
+	ErrDashboardContainsInvalidAlertData   = errors.New("Invalid alert data. Cannot save dashboard")
+	ErrDashboardFailedToUpdateAlertData    = errors.New("Failed to save alert data")
+	ErrDashboardsWithSameSlugExists        = errors.New("Multiple dashboards with the same slug exists")
+	ErrDashboardFailedGenerateUniqueUid    = errors.New("Failed to generate unique dashboard id")
 )
 )
 
 
 type UpdatePluginDashboardError struct {
 type UpdatePluginDashboardError struct {
@@ -39,6 +44,7 @@ var (
 // Dashboard model
 // Dashboard model
 type Dashboard struct {
 type Dashboard struct {
 	Id       int64
 	Id       int64
+	Uid      string
 	Slug     string
 	Slug     string
 	OrgId    int64
 	OrgId    int64
 	GnetId   int64
 	GnetId   int64
@@ -107,6 +113,10 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 		dash.GnetId = int64(gnetId)
 		dash.GnetId = int64(gnetId)
 	}
 	}
 
 
+	if uid, err := dash.Data.Get("uid").String(); err == nil {
+		dash.Uid = uid
+	}
+
 	return dash
 	return dash
 }
 }
 
 
@@ -147,6 +157,40 @@ func SlugifyTitle(title string) string {
 	return slug.Make(strings.ToLower(title))
 	return slug.Make(strings.ToLower(title))
 }
 }
 
 
+// GetUrl return the html url for a folder if it's folder, otherwise for a dashboard
+func (dash *Dashboard) GetUrl() string {
+	return GetDashboardFolderUrl(dash.IsFolder, dash.Uid, dash.Slug)
+}
+
+// Return the html url for a dashboard
+func (dash *Dashboard) GenerateUrl() string {
+	return GetDashboardUrl(dash.Uid, dash.Slug)
+}
+
+// GetDashboardFolderUrl return the html url for a folder if it's folder, otherwise for a dashboard
+func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string {
+	if isFolder {
+		return GetFolderUrl(uid, slug)
+	}
+
+	return GetDashboardUrl(uid, slug)
+}
+
+// Return the html url for a dashboard
+func GetDashboardUrl(uid string, slug string) string {
+	return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug)
+}
+
+// Return the full url for a dashboard
+func GetFullDashboardUrl(uid string, slug string) string {
+	return fmt.Sprintf("%s%s", setting.AppUrl, GetDashboardUrl(uid, slug))
+}
+
+// GetFolderUrl return the html url for a folder
+func GetFolderUrl(folderUid string, slug string) string {
+	return fmt.Sprintf("%s/dashboards/f/%s/%s", setting.AppSubUrl, folderUid, slug)
+}
+
 //
 //
 // COMMANDS
 // COMMANDS
 //
 //
@@ -177,8 +221,9 @@ type DeleteDashboardCommand struct {
 //
 //
 
 
 type GetDashboardQuery struct {
 type GetDashboardQuery struct {
-	Slug  string // required if no Id is specified
+	Slug  string // required if no Id or Uid is specified
 	Id    int64  // optional if slug is set
 	Id    int64  // optional if slug is set
+	Uid   string // optional if slug is set
 	OrgId int64
 	OrgId int64
 
 
 	Result *Dashboard
 	Result *Dashboard
@@ -199,6 +244,14 @@ type GetDashboardsQuery struct {
 	Result       []*Dashboard
 	Result       []*Dashboard
 }
 }
 
 
+type GetDashboardPermissionsForUserQuery struct {
+	DashboardIds []int64
+	OrgId        int64
+	UserId       int64
+	OrgRole      RoleType
+	Result       []*DashboardPermissionForUser
+}
+
 type GetDashboardsByPluginIdQuery struct {
 type GetDashboardsByPluginIdQuery struct {
 	OrgId    int64
 	OrgId    int64
 	PluginId string
 	PluginId string
@@ -209,3 +262,26 @@ type GetDashboardSlugByIdQuery struct {
 	Id     int64
 	Id     int64
 	Result string
 	Result string
 }
 }
+
+type GetDashboardsBySlugQuery struct {
+	OrgId int64
+	Slug  string
+
+	Result []*Dashboard
+}
+
+type DashboardPermissionForUser struct {
+	DashboardId    int64          `json:"dashboardId"`
+	Permission     PermissionType `json:"permission"`
+	PermissionName string         `json:"permissionName"`
+}
+
+type DashboardRef struct {
+	Uid  string
+	Slug string
+}
+
+type GetDashboardUIDByIdQuery struct {
+	Id     int64
+	Result *DashboardRef
+}

+ 16 - 12
pkg/models/folders.go

@@ -7,10 +7,11 @@ import (
 
 
 // Typed errors
 // Typed errors
 var (
 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")
-	ErrFolderWithSameNameExists = errors.New("A folder/dashboard with the same title already exists")
+	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 with the same uid already exists")
+	ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id")
 )
 )
 
 
 type Folder struct {
 type Folder struct {
@@ -28,12 +29,6 @@ type Folder struct {
 	HasAcl    bool
 	HasAcl    bool
 }
 }
 
 
-type GetFoldersQueryHitResult struct {
-	Id    int64  `json:"id"`
-	Title string `json:"title"`
-	Slug  string `json:"slug"`
-}
-
 //
 //
 // COMMANDS
 // COMMANDS
 //
 //
@@ -55,9 +50,18 @@ type UpdateFolderCommand struct {
 	Result *Folder
 	Result *Folder
 }
 }
 
 
-type GetFoldersQuery struct {
+//
+// QUERIES
+//
+
+type DashboardFolder struct {
+	Id    int64  `json:"id"`
+	Title string `json:"title"`
+}
+
+type GetFoldersForSignedInUserQuery struct {
 	OrgId        int64
 	OrgId        int64
 	SignedInUser *SignedInUser
 	SignedInUser *SignedInUser
 	Title        string
 	Title        string
-	Result       []*GetFoldersQueryHitResult
+	Result       []*DashboardFolder
 }
 }

+ 36 - 0
pkg/models/login_attempt.go

@@ -0,0 +1,36 @@
+package models
+
+import (
+	"time"
+)
+
+type LoginAttempt struct {
+	Id        int64
+	Username  string
+	IpAddress string
+	Created   time.Time
+}
+
+// ---------------------
+// COMMANDS
+
+type CreateLoginAttemptCommand struct {
+	Username  string
+	IpAddress string
+
+	Result LoginAttempt
+}
+
+type DeleteOldLoginAttemptsCommand struct {
+	OlderThan   time.Time
+	DeletedRows int64
+}
+
+// ---------------------
+// QUERIES
+
+type GetUserLoginAttemptCountQuery struct {
+	Username string
+	Since    time.Time
+	Result   int64
+}

+ 9 - 4
pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go

@@ -69,10 +69,14 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou
 
 
 	for _, r := range pbres.Results {
 	for _, r := range pbres.Results {
 		qr := &tsdb.QueryResult{
 		qr := &tsdb.QueryResult{
-			RefId:       r.RefId,
-			Series:      []*tsdb.TimeSeries{},
-			Error:       errors.New(r.Error),
-			ErrorString: r.Error,
+			RefId:  r.RefId,
+			Series: []*tsdb.TimeSeries{},
+			Tables: []*tsdb.Table{},
+		}
+
+		if r.Error != "" {
+			qr.Error = errors.New(r.Error)
+			qr.ErrorString = r.Error
 		}
 		}
 
 
 		for _, s := range r.GetSeries() {
 		for _, s := range r.GetSeries() {
@@ -121,6 +125,7 @@ func (tw *DatasourcePluginWrapper) mapTable(t *datasource.Table) (*tsdb.Table, e
 		})
 		})
 	}
 	}
 
 
+	table.Rows = make([]tsdb.RowValues, 0)
 	for _, r := range t.GetRows() {
 	for _, r := range t.GetRows() {
 		row := tsdb.RowValues{}
 		row := tsdb.RowValues{}
 		for _, rv := range r.Values {
 		for _, rv := range r.Values {

+ 1 - 1
pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go

@@ -75,7 +75,7 @@ func TestMappingRowValue(t *testing.T) {
 	boolRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BOOL, BoolValue: true})
 	boolRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BOOL, BoolValue: true})
 	haveBool, ok := boolRowValue.(bool)
 	haveBool, ok := boolRowValue.(bool)
 	if !ok || haveBool != true {
 	if !ok || haveBool != true {
-		t.Fatalf("Expected true, was %s", haveBool)
+		t.Fatalf("Expected true, was %v", haveBool)
 	}
 	}
 
 
 	intRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_INT64, Int64Value: 42})
 	intRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_INT64, Int64Value: 42})

+ 25 - 22
pkg/services/alerting/eval_context.go

@@ -12,17 +12,19 @@ import (
 )
 )
 
 
 type EvalContext struct {
 type EvalContext struct {
-	Firing          bool
-	IsTestRun       bool
-	EvalMatches     []*EvalMatch
-	Logs            []*ResultLogEntry
-	Error           error
-	ConditionEvals  string
-	StartTime       time.Time
-	EndTime         time.Time
-	Rule            *Rule
-	log             log.Logger
-	dashboardSlug   string
+	Firing         bool
+	IsTestRun      bool
+	EvalMatches    []*EvalMatch
+	Logs           []*ResultLogEntry
+	Error          error
+	ConditionEvals string
+	StartTime      time.Time
+	EndTime        time.Time
+	Rule           *Rule
+	log            log.Logger
+
+	dashboardRef *m.DashboardRef
+
 	ImagePublicUrl  string
 	ImagePublicUrl  string
 	ImageOnDiskPath string
 	ImageOnDiskPath string
 	NoDataFound     bool
 	NoDataFound     bool
@@ -83,29 +85,30 @@ func (c *EvalContext) GetNotificationTitle() string {
 	return "[" + c.GetStateModel().Text + "] " + c.Rule.Name
 	return "[" + c.GetStateModel().Text + "] " + c.Rule.Name
 }
 }
 
 
-func (c *EvalContext) GetDashboardSlug() (string, error) {
-	if c.dashboardSlug != "" {
-		return c.dashboardSlug, nil
+func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) {
+	if c.dashboardRef != nil {
+		return c.dashboardRef, nil
 	}
 	}
 
 
-	slugQuery := &m.GetDashboardSlugByIdQuery{Id: c.Rule.DashboardId}
-	if err := bus.Dispatch(slugQuery); err != nil {
-		return "", err
+	uidQuery := &m.GetDashboardUIDByIdQuery{Id: c.Rule.DashboardId}
+	if err := bus.Dispatch(uidQuery); err != nil {
+		return nil, err
 	}
 	}
 
 
-	c.dashboardSlug = slugQuery.Result
-	return c.dashboardSlug, nil
+	c.dashboardRef = uidQuery.Result
+	return c.dashboardRef, nil
 }
 }
 
 
+const urlFormat = "%s?fullscreen=true&edit=true&tab=alert&panelId=%d&orgId=%d"
+
 func (c *EvalContext) GetRuleUrl() (string, error) {
 func (c *EvalContext) GetRuleUrl() (string, error) {
 	if c.IsTestRun {
 	if c.IsTestRun {
 		return setting.AppUrl, nil
 		return setting.AppUrl, nil
 	}
 	}
 
 
-	if slug, err := c.GetDashboardSlug(); err != nil {
+	if ref, err := c.GetDashboardUID(); err != nil {
 		return "", err
 		return "", err
 	} else {
 	} else {
-		ruleUrl := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d&orgId=%d", setting.AppUrl, slug, c.Rule.PanelId, c.Rule.OrgId)
-		return ruleUrl, nil
+		return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil
 	}
 	}
 }
 }

+ 2 - 2
pkg/services/alerting/notifier.go

@@ -87,10 +87,10 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
 		IsAlertContext: true,
 		IsAlertContext: true,
 	}
 	}
 
 
-	if slug, err := context.GetDashboardSlug(); err != nil {
+	if ref, err := context.GetDashboardUID(); err != nil {
 		return err
 		return err
 	} else {
 	} else {
-		renderOpts.Path = fmt.Sprintf("dashboard-solo/db/%s?&panelId=%d", slug, context.Rule.PanelId)
+		renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?panelId=%d", ref.Uid, ref.Slug, context.Rule.PanelId)
 	}
 	}
 
 
 	if imagePath, err := renderer.RenderToPng(renderOpts); err != nil {
 	if imagePath, err := renderer.RenderToPng(renderOpts); err != nil {

+ 119 - 119
pkg/services/alerting/ticker_test.go

@@ -1,121 +1,121 @@
 package alerting
 package alerting
 
 
-import (
-	"testing"
-	"time"
-
-	"github.com/benbjohnson/clock"
-)
-
-func inspectTick(tick time.Time, last time.Time, offset time.Duration, t *testing.T) {
-	if !tick.Equal(last.Add(time.Duration(1) * time.Second)) {
-		t.Fatalf("expected a tick 1 second more than prev, %s. got: %s", last, tick)
-	}
-}
-
-// returns the new last tick seen
-func assertAdvanceUntil(ticker *Ticker, last, desiredLast time.Time, offset, wait time.Duration, t *testing.T) time.Time {
-	for {
-		select {
-		case tick := <-ticker.C:
-			inspectTick(tick, last, offset, t)
-			last = tick
-		case <-time.NewTimer(wait).C:
-			if last.Before(desiredLast) {
-				t.Fatalf("waited %s for ticker to advance to %s, but only went up to %s", wait, desiredLast, last)
-			}
-			if last.After(desiredLast) {
-				t.Fatalf("timer advanced too far. should only have gone up to %s, but it went up to %s", desiredLast, last)
-			}
-			return last
-		}
-	}
-}
-
-func assertNoAdvance(ticker *Ticker, desiredLast time.Time, wait time.Duration, t *testing.T) {
-	for {
-		select {
-		case tick := <-ticker.C:
-			t.Fatalf("timer should have stayed at %s, instead it advanced to %s", desiredLast, tick)
-		case <-time.NewTimer(wait).C:
-			return
-		}
-	}
-}
-
-func TestTickerRetro1Hour(t *testing.T) {
-	offset := time.Duration(10) * time.Second
-	last := time.Unix(0, 0)
-	mock := clock.NewMock()
-	mock.Add(time.Duration(1) * time.Hour)
-	desiredLast := mock.Now().Add(-offset)
-	ticker := NewTicker(last, offset, mock)
-
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-}
-
-func TestAdvanceWithUpdateOffset(t *testing.T) {
-	offset := time.Duration(10) * time.Second
-	last := time.Unix(0, 0)
-	mock := clock.NewMock()
-	mock.Add(time.Duration(1) * time.Hour)
-	desiredLast := mock.Now().Add(-offset)
-	ticker := NewTicker(last, offset, mock)
-
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-	// lowering offset should see a few more ticks
-	offset = time.Duration(5) * time.Second
-	ticker.updateOffset(offset)
-	desiredLast = mock.Now().Add(-offset)
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(9)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-	// advancing clock should see even more ticks
-	mock.Add(time.Duration(1) * time.Hour)
-	desiredLast = mock.Now().Add(-offset)
-	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(8)*time.Millisecond, t)
-	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-
-}
-
-func getCase(lastSeconds, offsetSeconds int) (time.Time, time.Duration) {
-	last := time.Unix(int64(lastSeconds), 0)
-	offset := time.Duration(offsetSeconds) * time.Second
-	return last, offset
-}
-
-func TestTickerNoAdvance(t *testing.T) {
-
-	// it's 00:01:00 now. what are some cases where we don't want the ticker to advance?
-	mock := clock.NewMock()
-	mock.Add(time.Duration(60) * time.Second)
-
-	type Case struct {
-		last   int
-		offset int
-	}
-
-	// note that some cases add up to now, others go into the future
-	cases := []Case{
-		{50, 10},
-		{50, 30},
-		{59, 1},
-		{59, 10},
-		{59, 30},
-		{60, 1},
-		{60, 10},
-		{60, 30},
-		{90, 1},
-		{90, 10},
-		{90, 30},
-	}
-	for _, c := range cases {
-		last, offset := getCase(c.last, c.offset)
-		ticker := NewTicker(last, offset, mock)
-		assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
-	}
-}
+//import (
+//	"testing"
+//	"time"
+//
+//	"github.com/benbjohnson/clock"
+//)
+//
+//func inspectTick(tick time.Time, last time.Time, offset time.Duration, t *testing.T) {
+//	if !tick.Equal(last.Add(time.Duration(1) * time.Second)) {
+//		t.Fatalf("expected a tick 1 second more than prev, %s. got: %s", last, tick)
+//	}
+//}
+//
+//// returns the new last tick seen
+//func assertAdvanceUntil(ticker *Ticker, last, desiredLast time.Time, offset, wait time.Duration, t *testing.T) time.Time {
+//	for {
+//		select {
+//		case tick := <-ticker.C:
+//			inspectTick(tick, last, offset, t)
+//			last = tick
+//		case <-time.NewTimer(wait).C:
+//			if last.Before(desiredLast) {
+//				t.Fatalf("waited %s for ticker to advance to %s, but only went up to %s", wait, desiredLast, last)
+//			}
+//			if last.After(desiredLast) {
+//				t.Fatalf("timer advanced too far. should only have gone up to %s, but it went up to %s", desiredLast, last)
+//			}
+//			return last
+//		}
+//	}
+//}
+//
+//func assertNoAdvance(ticker *Ticker, desiredLast time.Time, wait time.Duration, t *testing.T) {
+//	for {
+//		select {
+//		case tick := <-ticker.C:
+//			t.Fatalf("timer should have stayed at %s, instead it advanced to %s", desiredLast, tick)
+//		case <-time.NewTimer(wait).C:
+//			return
+//		}
+//	}
+//}
+//
+//func TestTickerRetro1Hour(t *testing.T) {
+//	offset := time.Duration(10) * time.Second
+//	last := time.Unix(0, 0)
+//	mock := clock.NewMock()
+//	mock.Add(time.Duration(1) * time.Hour)
+//	desiredLast := mock.Now().Add(-offset)
+//	ticker := NewTicker(last, offset, mock)
+//
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//}
+//
+//func TestAdvanceWithUpdateOffset(t *testing.T) {
+//	offset := time.Duration(10) * time.Second
+//	last := time.Unix(0, 0)
+//	mock := clock.NewMock()
+//	mock.Add(time.Duration(1) * time.Hour)
+//	desiredLast := mock.Now().Add(-offset)
+//	ticker := NewTicker(last, offset, mock)
+//
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(10)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//	// lowering offset should see a few more ticks
+//	offset = time.Duration(5) * time.Second
+//	ticker.updateOffset(offset)
+//	desiredLast = mock.Now().Add(-offset)
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(9)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//	// advancing clock should see even more ticks
+//	mock.Add(time.Duration(1) * time.Hour)
+//	desiredLast = mock.Now().Add(-offset)
+//	last = assertAdvanceUntil(ticker, last, desiredLast, offset, time.Duration(8)*time.Millisecond, t)
+//	assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//
+//}
+//
+//func getCase(lastSeconds, offsetSeconds int) (time.Time, time.Duration) {
+//	last := time.Unix(int64(lastSeconds), 0)
+//	offset := time.Duration(offsetSeconds) * time.Second
+//	return last, offset
+//}
+//
+//func TestTickerNoAdvance(t *testing.T) {
+//
+//	// it's 00:01:00 now. what are some cases where we don't want the ticker to advance?
+//	mock := clock.NewMock()
+//	mock.Add(time.Duration(60) * time.Second)
+//
+//	type Case struct {
+//		last   int
+//		offset int
+//	}
+//
+//	// note that some cases add up to now, others go into the future
+//	cases := []Case{
+//		{50, 10},
+//		{50, 30},
+//		{59, 1},
+//		{59, 10},
+//		{59, 30},
+//		{60, 1},
+//		{60, 10},
+//		{60, 30},
+//		{90, 1},
+//		{90, 10},
+//		{90, 30},
+//	}
+//	for _, c := range cases {
+//		last, offset := getCase(c.last, c.offset)
+//		ticker := NewTicker(last, offset, mock)
+//		assertNoAdvance(ticker, last, time.Duration(500)*time.Millisecond, t)
+//	}
+//}

+ 16 - 0
pkg/services/cleanup/cleanup.go

@@ -46,6 +46,7 @@ func (service *CleanUpService) start(ctx context.Context) error {
 			service.cleanUpTmpFiles()
 			service.cleanUpTmpFiles()
 			service.deleteExpiredSnapshots()
 			service.deleteExpiredSnapshots()
 			service.deleteExpiredDashboardVersions()
 			service.deleteExpiredDashboardVersions()
+			service.deleteOldLoginAttempts()
 		case <-ctx.Done():
 		case <-ctx.Done():
 			return ctx.Err()
 			return ctx.Err()
 		}
 		}
@@ -88,3 +89,18 @@ func (service *CleanUpService) deleteExpiredSnapshots() {
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
 	bus.Dispatch(&m.DeleteExpiredVersionsCommand{})
 	bus.Dispatch(&m.DeleteExpiredVersionsCommand{})
 }
 }
+
+func (service *CleanUpService) deleteOldLoginAttempts() {
+	if setting.DisableBruteForceLoginProtection {
+		return
+	}
+
+	cmd := m.DeleteOldLoginAttemptsCommand{
+		OlderThan: time.Now().Add(time.Minute * -10),
+	}
+	if err := bus.Dispatch(&cmd); err != nil {
+		service.log.Error("Problem deleting expired login attempts", "error", err.Error())
+	} else {
+		service.log.Debug("Deleted expired login attempts", "rows affected", cmd.DeletedRows)
+	}
+}

+ 2 - 1
pkg/services/search/models.go

@@ -13,9 +13,10 @@ const (
 
 
 type Hit struct {
 type Hit struct {
 	Id          int64    `json:"id"`
 	Id          int64    `json:"id"`
+	Uid         string   `json:"uid"`
 	Title       string   `json:"title"`
 	Title       string   `json:"title"`
 	Uri         string   `json:"uri"`
 	Uri         string   `json:"uri"`
-	Slug        string   `json:"slug"`
+	Url         string   `json:"url"`
 	Type        HitType  `json:"type"`
 	Type        HitType  `json:"type"`
 	Tags        []string `json:"tags"`
 	Tags        []string `json:"tags"`
 	IsStarred   bool     `json:"isStarred"`
 	IsStarred   bool     `json:"isStarred"`

+ 0 - 7
pkg/services/sqlstore/alert.go

@@ -14,7 +14,6 @@ func init() {
 	bus.AddHandler("sql", SaveAlerts)
 	bus.AddHandler("sql", SaveAlerts)
 	bus.AddHandler("sql", HandleAlertsQuery)
 	bus.AddHandler("sql", HandleAlertsQuery)
 	bus.AddHandler("sql", GetAlertById)
 	bus.AddHandler("sql", GetAlertById)
-	bus.AddHandler("sql", DeleteAlertById)
 	bus.AddHandler("sql", GetAllAlertQueryHandler)
 	bus.AddHandler("sql", GetAllAlertQueryHandler)
 	bus.AddHandler("sql", SetAlertState)
 	bus.AddHandler("sql", SetAlertState)
 	bus.AddHandler("sql", GetAlertStatesForDashboard)
 	bus.AddHandler("sql", GetAlertStatesForDashboard)
@@ -61,12 +60,6 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro
 	return nil
 	return nil
 }
 }
 
 
-func DeleteAlertById(cmd *m.DeleteAlertCommand) error {
-	return inTransaction(func(sess *DBSession) error {
-		return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess)
-	})
-}
-
 func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 	var sql bytes.Buffer
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 	params := make([]interface{}, 0)

+ 190 - 30
pkg/services/sqlstore/dashboard.go

@@ -1,12 +1,14 @@
 package sqlstore
 package sqlstore
 
 
 import (
 import (
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/services/search"
+	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 func init() {
 func init() {
@@ -17,18 +19,23 @@ func init() {
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardSlugById)
 	bus.AddHandler("sql", GetDashboardSlugById)
+	bus.AddHandler("sql", GetDashboardUIDById)
 	bus.AddHandler("sql", GetDashboardsByPluginId)
 	bus.AddHandler("sql", GetDashboardsByPluginId)
 	bus.AddHandler("sql", GetFoldersForSignedInUser)
 	bus.AddHandler("sql", GetFoldersForSignedInUser)
+	bus.AddHandler("sql", GetDashboardPermissionsForUser)
+	bus.AddHandler("sql", GetDashboardsBySlug)
 }
 }
 
 
+var generateNewUid func() string = util.GenerateShortUid
+
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
 		dash := cmd.GetDashboardModel()
 		dash := cmd.GetDashboardModel()
 
 
 		// try get existing dashboard
 		// try get existing dashboard
-		var existing, sameTitle m.Dashboard
+		var existing m.Dashboard
 
 
-		if dash.Id > 0 {
+		if dash.Id != 0 {
 			dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
 			dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
@@ -53,28 +60,43 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 
 
 			dash.Created = existing.Created
 			dash.Created = existing.Created
 			dash.CreatedBy = existing.CreatedBy
 			dash.CreatedBy = existing.CreatedBy
-		}
-
-		sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle)
-		if err != nil {
-			return err
-		}
+		} else if dash.Uid != "" {
+			var sameUid m.Dashboard
+			sameUidExists, err := sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&sameUid)
+			if err != nil {
+				return err
+			}
 
 
-		if sameTitleExists {
-			// another dashboard with same name
-			if dash.Id != sameTitle.Id {
-				if cmd.Overwrite {
-					dash.Id = sameTitle.Id
-					dash.Version = sameTitle.Version
+			if sameUidExists {
+				// another dashboard with same uid
+				if dash.Id != sameUid.Id {
+					if cmd.Overwrite {
+						dash.Id = sameUid.Id
+						dash.Version = sameUid.Version
+					} else {
+						return m.ErrDashboardWithSameUIDExists
+					}
 				} else {
 				} else {
-					return m.ErrDashboardWithSameNameExists
+					dash.Created = sameUid.Created
+					dash.CreatedBy = sameUid.CreatedBy
 				}
 				}
-			} else {
-				dash.Created = sameTitle.Created
-				dash.CreatedBy = sameTitle.CreatedBy
 			}
 			}
 		}
 		}
 
 
+		if dash.Uid == "" {
+			uid, err := generateNewDashboardUid(sess, dash.OrgId)
+			if err != nil {
+				return err
+			}
+			dash.Uid = uid
+			dash.Data.Set("uid", uid)
+		}
+
+		err := guaranteeDashboardNameIsUniqueInFolder(sess, dash)
+		if err != nil {
+			return err
+		}
+
 		err = setHasAcl(sess, dash)
 		err = setHasAcl(sess, dash)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -96,7 +118,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 				dash.Updated = cmd.UpdatedAt
 				dash.Updated = cmd.UpdatedAt
 			}
 			}
 
 
-			affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
+			affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash)
 		}
 		}
 
 
 		if err != nil {
 		if err != nil {
@@ -147,6 +169,40 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	})
 	})
 }
 }
 
 
+func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
+	for i := 0; i < 3; i++ {
+		uid := generateNewUid()
+
+		exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.Dashboard{})
+		if err != nil {
+			return "", err
+		}
+
+		if !exists {
+			return uid, nil
+		}
+	}
+
+	return "", m.ErrDashboardFailedGenerateUniqueUid
+}
+
+func guaranteeDashboardNameIsUniqueInFolder(sess *DBSession, dash *m.Dashboard) error {
+	var sameNameInFolder m.Dashboard
+	sameNameInFolderExist, err := sess.Where("org_id=? AND title=? AND folder_id = ? AND uid <> ?",
+		dash.OrgId, dash.Title, dash.FolderId, dash.Uid).
+		Get(&sameNameInFolder)
+
+	if err != nil {
+		return err
+	}
+
+	if sameNameInFolderExist {
+		return m.ErrDashboardWithSameNameInFolderExists
+	}
+
+	return nil
+}
+
 func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 	// check if parent has acl
 	// check if parent has acl
 	if dash.FolderId > 0 {
 	if dash.FolderId > 0 {
@@ -173,7 +229,7 @@ func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 }
 }
 
 
 func GetDashboard(query *m.GetDashboardQuery) error {
 func GetDashboard(query *m.GetDashboardQuery) error {
-	dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id}
+	dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid}
 	has, err := x.Get(&dashboard)
 	has, err := x.Get(&dashboard)
 
 
 	if err != nil {
 	if err != nil {
@@ -183,12 +239,14 @@ func GetDashboard(query *m.GetDashboardQuery) error {
 	}
 	}
 
 
 	dashboard.Data.Set("id", dashboard.Id)
 	dashboard.Data.Set("id", dashboard.Id)
+	dashboard.Data.Set("uid", dashboard.Uid)
 	query.Result = &dashboard
 	query.Result = &dashboard
 	return nil
 	return nil
 }
 }
 
 
 type DashboardSearchProjection struct {
 type DashboardSearchProjection struct {
 	Id          int64
 	Id          int64
+	Uid         string
 	Title       string
 	Title       string
 	Slug        string
 	Slug        string
 	Term        string
 	Term        string
@@ -266,15 +324,17 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
 		if !exists {
 		if !exists {
 			hit = &search.Hit{
 			hit = &search.Hit{
 				Id:          item.Id,
 				Id:          item.Id,
+				Uid:         item.Uid,
 				Title:       item.Title,
 				Title:       item.Title,
 				Uri:         "db/" + item.Slug,
 				Uri:         "db/" + item.Slug,
-				Slug:        item.Slug,
+				Url:         m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
 				Type:        getHitType(item),
 				Type:        getHitType(item),
 				FolderId:    item.FolderId,
 				FolderId:    item.FolderId,
 				FolderTitle: item.FolderTitle,
 				FolderTitle: item.FolderTitle,
 				FolderSlug:  item.FolderSlug,
 				FolderSlug:  item.FolderSlug,
 				Tags:        []string{},
 				Tags:        []string{},
 			}
 			}
+
 			query.Result = append(query.Result, hit)
 			query.Result = append(query.Result, hit)
 			hits[item.Id] = hit
 			hits[item.Id] = hit
 		}
 		}
@@ -299,13 +359,13 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
 	return err
 	return err
 }
 }
 
 
-func GetFoldersForSignedInUser(query *m.GetFoldersQuery) error {
-	query.Result = make([]*m.GetFoldersQueryHitResult, 0)
+func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
+	query.Result = make([]*m.DashboardFolder, 0)
 	var err error
 	var err error
 	params := make([]interface{}, 0)
 	params := make([]interface{}, 0)
 
 
 	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
 	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
-		sql := `SELECT distinct d.id, d.title, d.slug
+		sql := `SELECT distinct d.id, d.title
 		FROM dashboard AS d WHERE d.is_folder = ?`
 		FROM dashboard AS d WHERE d.is_folder = ?`
 		params = append(params, dialect.BooleanStr(true))
 		params = append(params, dialect.BooleanStr(true))
 
 
@@ -318,21 +378,22 @@ func GetFoldersForSignedInUser(query *m.GetFoldersQuery) error {
 
 
 		err = x.Sql(sql, params...).Find(&query.Result)
 		err = x.Sql(sql, params...).Find(&query.Result)
 	} else {
 	} else {
-		sql := `SELECT distinct d.id, d.title, d.slug
+		sql := `SELECT distinct d.id, d.title
 		FROM dashboard AS d
 		FROM dashboard AS d
 			LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id
 			LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id
 			LEFT JOIN team_member AS ugm ON ugm.team_id =  da.team_id
 			LEFT JOIN team_member AS ugm ON ugm.team_id =  da.team_id
 			LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
 			LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
-			LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ?`
+			LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ? AND ouRole.org_id = ?`
 		params = append(params, query.SignedInUser.UserId)
 		params = append(params, query.SignedInUser.UserId)
 		params = append(params, query.SignedInUser.UserId)
 		params = append(params, query.SignedInUser.UserId)
+		params = append(params, query.OrgId)
 
 
-		sql += `WHERE
+		sql += ` WHERE
 			d.org_id = ? AND
 			d.org_id = ? AND
 			d.is_folder = ? AND
 			d.is_folder = ? AND
 			(
 			(
 				(d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
 				(d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
-				OR (d.has_acl = 0 AND ouRole.id IS NOT NULL)
+				OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
 			)`
 			)`
 		params = append(params, query.OrgId)
 		params = append(params, query.OrgId)
 		params = append(params, dialect.BooleanStr(true))
 		params = append(params, dialect.BooleanStr(true))
@@ -347,7 +408,6 @@ func GetFoldersForSignedInUser(query *m.GetFoldersQuery) error {
 		}
 		}
 
 
 		sql += ` ORDER BY d.title ASC`
 		sql += ` ORDER BY d.title ASC`
-
 		err = x.Sql(sql, params...).Find(&query.Result)
 		err = x.Sql(sql, params...).Find(&query.Result)
 	}
 	}
 
 
@@ -406,6 +466,78 @@ func GetDashboards(query *m.GetDashboardsQuery) error {
 	return nil
 	return nil
 }
 }
 
 
+// GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s)
+// The function takes in a list of dashboard ids and the user id and role
+func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery) error {
+	if len(query.DashboardIds) == 0 {
+		return m.ErrCommandValidationFailed
+	}
+
+	if query.OrgRole == m.ROLE_ADMIN {
+		var permissions = make([]*m.DashboardPermissionForUser, 0)
+		for _, d := range query.DashboardIds {
+			permissions = append(permissions, &m.DashboardPermissionForUser{
+				DashboardId:    d,
+				Permission:     m.PERMISSION_ADMIN,
+				PermissionName: m.PERMISSION_ADMIN.String(),
+			})
+		}
+		query.Result = permissions
+
+		return nil
+	}
+
+	params := make([]interface{}, 0)
+
+	// check dashboards that have ACLs via user id, team id or role
+	sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission
+	FROM dashboard AS d
+		LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id
+		LEFT JOIN team_member as ugm on ugm.team_id =  da.team_id
+		LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
+	`
+	params = append(params, query.UserId)
+
+	//check the user's role for dashboards that do not have hasAcl set
+	sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?`
+	params = append(params, query.UserId)
+	params = append(params, query.OrgId)
+
+	sql += `
+		LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role
+			UNION SELECT 2 AS permission, 'Editor' AS role
+			UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role
+	WHERE
+	d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
+	for _, id := range query.DashboardIds {
+		params = append(params, id)
+	}
+
+	sql += ` AND
+	d.org_id = ? AND
+	  (
+		(d.has_acl = ?  AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
+		OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
+	)
+	group by d.id
+	order by d.id asc`
+	params = append(params, query.OrgId)
+	params = append(params, dialect.BooleanStr(true))
+	params = append(params, query.UserId)
+	params = append(params, query.UserId)
+	params = append(params, dialect.BooleanStr(false))
+
+	x.ShowSQL(true)
+	err := x.Sql(sql, params...).Find(&query.Result)
+	x.ShowSQL(false)
+
+	for _, p := range query.Result {
+		p.PermissionName = p.Permission.String()
+	}
+
+	return err
+}
+
 func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error {
 func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error {
 	var dashboards = make([]*m.Dashboard, 0)
 	var dashboards = make([]*m.Dashboard, 0)
 	whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)
 	whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)
@@ -428,7 +560,7 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
 	var rawSql = `SELECT slug from dashboard WHERE Id=?`
 	var rawSql = `SELECT slug from dashboard WHERE Id=?`
 	var slug = DashboardSlugDTO{}
 	var slug = DashboardSlugDTO{}
 
 
-	exists, err := x.Sql(rawSql, query.Id).Get(&slug)
+	exists, err := x.SQL(rawSql, query.Id).Get(&slug)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -439,3 +571,31 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
 	query.Result = slug.Slug
 	query.Result = slug.Slug
 	return nil
 	return nil
 }
 }
+
+func GetDashboardsBySlug(query *m.GetDashboardsBySlugQuery) error {
+	var dashboards []*m.Dashboard
+
+	if err := x.Where("org_id=? AND slug=?", query.OrgId, query.Slug).Find(&dashboards); err != nil {
+		return err
+	}
+
+	query.Result = dashboards
+	return nil
+}
+
+func GetDashboardUIDById(query *m.GetDashboardUIDByIdQuery) error {
+	var rawSql = `SELECT uid, slug from dashboard WHERE Id=?`
+
+	us := &m.DashboardRef{}
+
+	exists, err := x.SQL(rawSql, query.Id).Get(us)
+
+	if err != nil {
+		return err
+	} else if exists == false {
+		return m.ErrDashboardNotFound
+	}
+
+	query.Result = us
+	return nil
+}

+ 69 - 46
pkg/services/sqlstore/dashboard_acl.go

@@ -126,14 +126,10 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
 }
 }
 
 
 func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
-	dashboardFilter := fmt.Sprintf(`IN (
-    SELECT %d
-    UNION
-    SELECT folder_id from dashboard where id = %d
-  )`, query.DashboardId, query.DashboardId)
-
-	rawSQL := `
-	SELECT
+	var err error
+
+	if query.DashboardId == 0 {
+		sql := `SELECT
 		da.id,
 		da.id,
 		da.org_id,
 		da.org_id,
 		da.dashboard_id,
 		da.dashboard_id,
@@ -143,44 +139,71 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		da.role,
 		da.role,
 		da.created,
 		da.created,
 		da.updated,
 		da.updated,
-		u.login AS user_login,
-		u.email AS user_email,
-		ug.name AS team
-  FROM` + dialect.Quote("dashboard_acl") + ` as da
-		LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
-		LEFT OUTER JOIN team ug on ug.id = da.team_id
-	WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
-
-	-- Also include default permission if has_acl = 0
-
-	UNION
-		SELECT
-			da.id,
-			da.org_id,
-			da.dashboard_id,
-			da.user_id,
-			da.team_id,
-			da.permission,
-			da.role,
-			da.created,
-			da.updated,
-			'' as user_login,
-			'' as user_email,
-			'' as team
-			FROM dashboard_acl as da,
-        dashboard as dash
-        LEFT JOIN dashboard folder on dash.folder_id = folder.id
-			WHERE
-				dash.id = ? AND (
-					dash.has_acl = ` + dialect.BooleanStr(false) + ` or
-					folder.has_acl = ` + dialect.BooleanStr(false) + `
-				) AND
-				da.dashboard_id = -1
-	ORDER BY 1 ASC
-	`
-
-	query.Result = make([]*m.DashboardAclInfoDTO, 0)
-	err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
+		'' as user_login,
+		'' as user_email,
+		'' as team
+		FROM dashboard_acl as da
+		WHERE da.dashboard_id = -1`
+		query.Result = make([]*m.DashboardAclInfoDTO, 0)
+		err = x.SQL(sql).Find(&query.Result)
+
+	} else {
+		dashboardFilter := fmt.Sprintf(`IN (
+			SELECT %d
+			UNION
+			SELECT folder_id from dashboard where id = %d
+		  )`, query.DashboardId, query.DashboardId)
+
+		rawSQL := `
+			SELECT
+				da.id,
+				da.org_id,
+				da.dashboard_id,
+				da.user_id,
+				da.team_id,
+				da.permission,
+				da.role,
+				da.created,
+				da.updated,
+				u.login AS user_login,
+				u.email AS user_email,
+				ug.name AS team
+		  FROM` + dialect.Quote("dashboard_acl") + ` as da
+				LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
+				LEFT OUTER JOIN team ug on ug.id = da.team_id
+			WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
+
+			-- Also include default permission if has_acl = 0
+
+			UNION
+				SELECT
+					da.id,
+					da.org_id,
+					da.dashboard_id,
+					da.user_id,
+					da.team_id,
+					da.permission,
+					da.role,
+					da.created,
+					da.updated,
+					'' as user_login,
+					'' as user_email,
+					'' as team
+					FROM dashboard_acl as da,
+				dashboard as dash
+				LEFT JOIN dashboard folder on dash.folder_id = folder.id
+					WHERE
+						dash.id = ? AND (
+							dash.has_acl = ` + dialect.BooleanStr(false) + ` or
+							folder.has_acl = ` + dialect.BooleanStr(false) + `
+						) AND
+						da.dashboard_id = -1
+			ORDER BY 1 ASC
+			`
+
+		query.Result = make([]*m.DashboardAclInfoDTO, 0)
+		err = x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
+	}
 
 
 	for _, p := range query.Result {
 	for _, p := range query.Result {
 		p.PermissionName = p.Permission.String()
 		p.PermissionName = p.Permission.String()

+ 18 - 0
pkg/services/sqlstore/dashboard_acl_test.go

@@ -232,5 +232,23 @@ func TestDashboardAclDataAccess(t *testing.T) {
 
 
 			})
 			})
 		})
 		})
+
+		Convey("Given a root folder", func() {
+			var rootFolderId int64 = 0
+
+			Convey("When reading dashboard acl should return default permissions", func() {
+				query := m.GetDashboardAclInfoListQuery{DashboardId: rootFolderId, OrgId: 1}
+
+				err := GetDashboardAclInfoList(&query)
+				So(err, ShouldBeNil)
+
+				So(len(query.Result), ShouldEqual, 2)
+				defaultPermissionsId := -1
+				So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId)
+				So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER)
+				So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId)
+				So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR)
+			})
+		})
 	})
 	})
 }
 }

+ 349 - 0
pkg/services/sqlstore/dashboard_folder_test.go

@@ -0,0 +1,349 @@
+package sqlstore
+
+import (
+	"testing"
+
+	"github.com/go-xorm/xorm"
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+func TestDashboardFolderDataAccess(t *testing.T) {
+	var x *xorm.Engine
+
+	Convey("Testing DB", t, func() {
+		x = InitTestDB(t)
+
+		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
+			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
+			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
+			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+
+			Convey("and no acls are set", func() {
+				Convey("should return all dashboards", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder.Id)
+					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission", func() {
+					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+
+			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
+				var otherUser int64 = 999
+				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+				removeAcl(aclId)
+				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder or child", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission to child", func() {
+					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to search for child dashboard but not folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, childDash.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to search for child dash and folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
+			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
+			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+			var rootFolderId int64 = 0
+
+			Convey("and one folder is expanded, the other collapsed", func() {
+				Convey("should return dashboards in root and expanded folder", func() {
+					query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 4)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+					So(query.Result[2].Id, ShouldEqual, childDash1.Id)
+					So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for one dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
+					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should not return folder with acl or its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 1)
+						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeFalse)
+
+					Convey("should return folder without acl and its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 4)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
+						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
+					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should return folder without acl but not the dashboard with acl", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders", func() {
+
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+
+			adminUser := createUser("admin", "Admin", true)
+			editorUser := createUser("editor", "Editor", false)
+			viewerUser := createUser("viewer", "Viewer", false)
+
+			Convey("Admin users", func() {
+				Convey("Should have write access to all dashboard folders", func() {
+					query := m.GetFoldersForSignedInUserQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
+					}
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+				})
+
+				Convey("should have write access to all folders and dashboards", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       adminUser.Id,
+						OrgRole:      m.ROLE_ADMIN,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+				})
+			})
+
+			Convey("Editor users", func() {
+				query := m.GetFoldersForSignedInUserQuery{
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR},
+				}
+
+				Convey("Should have write access to all dashboard folders with default ACL", func() {
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+				})
+
+				Convey("should have edit access to folders with default ACL", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       editorUser.Id,
+						OrgRole:      m.ROLE_EDITOR,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT)
+				})
+
+				Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() {
+					updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW)
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, folder2.Id)
+				})
+
+			})
+
+			Convey("Viewer users", func() {
+				query := m.GetFoldersForSignedInUserQuery{
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER},
+				}
+
+				Convey("Should have no write access to any dashboard folders with default ACL", func() {
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 0)
+				})
+
+				Convey("should have view access to folders with default ACL", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       viewerUser.Id,
+						OrgRole:      m.ROLE_VIEWER,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW)
+				})
+
+				Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() {
+					updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT)
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+				})
+			})
+		})
+	})
+}

+ 196 - 280
pkg/services/sqlstore/dashboard_test.go

@@ -1,15 +1,16 @@
 package sqlstore
 package sqlstore
 
 
 import (
 import (
+	"fmt"
 	"testing"
 	"testing"
 
 
 	"github.com/go-xorm/xorm"
 	"github.com/go-xorm/xorm"
-	. "github.com/smartystreets/goconvey/convey"
-
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+	. "github.com/smartystreets/goconvey/convey"
 )
 )
 
 
 func TestDashboardDataAccess(t *testing.T) {
 func TestDashboardDataAccess(t *testing.T) {
@@ -30,15 +31,33 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(savedDash.Id, ShouldNotEqual, 0)
 				So(savedDash.Id, ShouldNotEqual, 0)
 				So(savedDash.IsFolder, ShouldBeFalse)
 				So(savedDash.IsFolder, ShouldBeFalse)
 				So(savedDash.FolderId, ShouldBeGreaterThan, 0)
 				So(savedDash.FolderId, ShouldBeGreaterThan, 0)
+				So(len(savedDash.Uid), ShouldBeGreaterThan, 0)
 
 
 				So(savedFolder.Title, ShouldEqual, "1 test dash folder")
 				So(savedFolder.Title, ShouldEqual, "1 test dash folder")
 				So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
 				So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
 				So(savedFolder.Id, ShouldNotEqual, 0)
 				So(savedFolder.Id, ShouldNotEqual, 0)
 				So(savedFolder.IsFolder, ShouldBeTrue)
 				So(savedFolder.IsFolder, ShouldBeTrue)
 				So(savedFolder.FolderId, ShouldEqual, 0)
 				So(savedFolder.FolderId, ShouldEqual, 0)
+				So(len(savedFolder.Uid), ShouldBeGreaterThan, 0)
+			})
+
+			Convey("Should be able to get dashboard by id", func() {
+				query := m.GetDashboardQuery{
+					Id:    savedDash.Id,
+					OrgId: 1,
+				}
+
+				err := GetDashboard(&query)
+				So(err, ShouldBeNil)
+
+				So(query.Result.Title, ShouldEqual, "test dash 23")
+				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.Id, ShouldEqual, savedDash.Id)
+				So(query.Result.Uid, ShouldEqual, savedDash.Uid)
+				So(query.Result.IsFolder, ShouldBeFalse)
 			})
 			})
 
 
-			Convey("Should be able to get dashboard", func() {
+			Convey("Should be able to get dashboard by slug", func() {
 				query := m.GetDashboardQuery{
 				query := m.GetDashboardQuery{
 					Slug:  "test-dash-23",
 					Slug:  "test-dash-23",
 					OrgId: 1,
 					OrgId: 1,
@@ -49,6 +68,24 @@ func TestDashboardDataAccess(t *testing.T) {
 
 
 				So(query.Result.Title, ShouldEqual, "test dash 23")
 				So(query.Result.Title, ShouldEqual, "test dash 23")
 				So(query.Result.Slug, ShouldEqual, "test-dash-23")
 				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.Id, ShouldEqual, savedDash.Id)
+				So(query.Result.Uid, ShouldEqual, savedDash.Uid)
+				So(query.Result.IsFolder, ShouldBeFalse)
+			})
+
+			Convey("Should be able to get dashboard by uid", func() {
+				query := m.GetDashboardQuery{
+					Uid:   savedDash.Uid,
+					OrgId: 1,
+				}
+
+				err := GetDashboard(&query)
+				So(err, ShouldBeNil)
+
+				So(query.Result.Title, ShouldEqual, "test dash 23")
+				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.Id, ShouldEqual, savedDash.Id)
+				So(query.Result.Uid, ShouldEqual, savedDash.Uid)
 				So(query.Result.IsFolder, ShouldBeFalse)
 				So(query.Result.IsFolder, ShouldBeFalse)
 			})
 			})
 
 
@@ -109,6 +146,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(len(query.Result), ShouldEqual, 1)
 				So(len(query.Result), ShouldEqual, 1)
 				hit := query.Result[0]
 				hit := query.Result[0]
 				So(hit.Type, ShouldEqual, search.DashHitFolder)
 				So(hit.Type, ShouldEqual, search.DashHitFolder)
+				So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
 			})
 			})
 
 
 			Convey("Should be able to search for a dashboard folder's children", func() {
 			Convey("Should be able to search for a dashboard folder's children", func() {
@@ -124,6 +162,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(len(query.Result), ShouldEqual, 2)
 				So(len(query.Result), ShouldEqual, 2)
 				hit := query.Result[0]
 				hit := query.Result[0]
 				So(hit.Id, ShouldEqual, savedDash.Id)
 				So(hit.Id, ShouldEqual, savedDash.Id)
+				So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug))
 			})
 			})
 
 
 			Convey("Should be able to search for dashboard by dashboard ids", func() {
 			Convey("Should be able to search for dashboard by dashboard ids", func() {
@@ -157,20 +196,172 @@ func TestDashboardDataAccess(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			Convey("Should not be able to save dashboard with same name", func() {
+			Convey("Should be able to save dashboards with same name in different folders", func() {
+				firstSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "randomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err := SaveDashboard(&firstSaveCmd)
+				So(err, ShouldBeNil)
+
+				secondSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "moreRandomHash",
+					}),
+					FolderId: 1,
+				}
+
+				err = SaveDashboard(&secondSaveCmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should not be able to save dashboard with same name in the same folder", func() {
+				firstSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "randomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err := SaveDashboard(&firstSaveCmd)
+				So(err, ShouldBeNil)
+
+				secondSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "moreRandomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err = SaveDashboard(&secondSaveCmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists)
+			})
+
+			Convey("Should not be able to save dashboard with same uid", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 						"id":    nil,
 						"id":    nil,
 						"title": "test dash 23",
 						"title": "test dash 23",
-						"tags":  []interface{}{},
+						"uid":   "dsfalkjngailuedt",
 					}),
 					}),
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				err = SaveDashboard(&cmd)
 				So(err, ShouldNotBeNil)
 				So(err, ShouldNotBeNil)
 			})
 			})
 
 
+			Convey("Should be able to update dashboard with the same title and folder id", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":   "randomHash",
+						"title": "folderId",
+						"style": "light",
+						"tags":  []interface{}{},
+					}),
+					FolderId: 2,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.FolderId, ShouldEqual, 2)
+
+				cmd = m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":      cmd.Result.Id,
+						"uid":     "randomHash",
+						"title":   "folderId",
+						"style":   "dark",
+						"version": cmd.Result.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId: 2,
+				}
+
+				err = SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should not be able to update using just uid", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":     savedDash.Uid,
+						"title":   "folderId",
+						"version": savedDash.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId: savedDash.FolderId,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameUIDExists)
+			})
+
+			Convey("Should be able to update using just uid with overwrite", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":     savedDash.Uid,
+						"title":   "folderId",
+						"version": savedDash.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId:  savedDash.FolderId,
+					Overwrite: true,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should retry generation of uid once if it fails.", func() {
+				timesCalled := 0
+				generateNewUid = func() string {
+					timesCalled += 1
+					if timesCalled <= 2 {
+						return savedDash.Uid
+					} else {
+						return util.GenerateShortUid()
+					}
+				}
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "new dash 12334",
+						"tags":  []interface{}{},
+					}),
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+
+				generateNewUid = util.GenerateShortUid
+			})
+
 			Convey("Should be able to update dashboard and remove folderId", func() {
 			Convey("Should be able to update dashboard and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
@@ -260,281 +451,6 @@ func TestDashboardDataAccess(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
-		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
-			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
-			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
-			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
-			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
-
-			currentUser := createUser("viewer", "Viewer", false)
-
-			Convey("and no acls are set", func() {
-				Convey("should return all dashboards", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder.Id)
-					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-				})
-			})
-
-			Convey("and acl is set for dashboard folder", func() {
-				var otherUser int64 = 999
-				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("should not return folder", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-				})
-
-				Convey("when the user is given permission", func() {
-					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
-
-					Convey("should be able to access folder", func() {
-						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("when the user is an admin", func() {
-					Convey("should be able to access folder", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{
-								UserId:  currentUser.Id,
-								OrgId:   1,
-								OrgRole: m.ROLE_ADMIN,
-							},
-							OrgId:        1,
-							DashboardIds: []int64{folder.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-
-			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
-				var otherUser int64 = 999
-				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
-				removeAcl(aclId)
-				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("should not return folder or child", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-				})
-
-				Convey("when the user is given permission to child", func() {
-					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
-
-					Convey("should be able to search for child dashboard but not folder", func() {
-						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, childDash.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("when the user is an admin", func() {
-					Convey("should be able to search for child dash and folder", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{
-								UserId:  currentUser.Id,
-								OrgId:   1,
-								OrgRole: m.ROLE_ADMIN,
-							},
-							OrgId:        1,
-							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 3)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash.Id)
-						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-		})
-
-		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
-			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
-			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
-			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
-			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
-			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
-
-			currentUser := createUser("viewer", "Viewer", false)
-			var rootFolderId int64 = 0
-
-			Convey("and one folder is expanded, the other collapsed", func() {
-				Convey("should return dashboards in root and expanded folder", func() {
-					query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 4)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-					So(query.Result[2].Id, ShouldEqual, childDash1.Id)
-					So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
-				})
-			})
-
-			Convey("and acl is set for one dashboard folder", func() {
-				var otherUser int64 = 999
-				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
-					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
-					So(movedDash.HasAcl, ShouldBeTrue)
-
-					Convey("should not return folder with acl or its children", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 1)
-						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
-					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
-					So(movedDash.HasAcl, ShouldBeFalse)
-
-					Convey("should return folder without acl and its children", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 4)
-						So(query.Result[0].Id, ShouldEqual, folder2.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
-						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
-						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
-					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
-					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
-					So(movedDash.HasAcl, ShouldBeTrue)
-
-					Convey("should return folder without acl but not the dashboard with acl", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 3)
-						So(query.Result[0].Id, ShouldEqual, folder2.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
-						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-		})
-
-		Convey("Given two dashboard folders", func() {
-
-			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
-			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
-
-			adminUser := createUser("admin", "Admin", true)
-			editorUser := createUser("editor", "Editor", false)
-			viewerUser := createUser("viewer", "Viewer", false)
-
-			Convey("Admin users", func() {
-				Convey("Should have write access to all dashboard folders", func() {
-					query := m.GetFoldersQuery{
-						OrgId:        1,
-						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
-					}
-
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-				})
-			})
-
-			Convey("Editor users", func() {
-				query := m.GetFoldersQuery{
-					OrgId:        1,
-					SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR},
-				}
-
-				Convey("Should have write access to all dashboard folders with default ACL", func() {
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-				})
-
-				Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() {
-					updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW)
-
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, folder2.Id)
-				})
-			})
-
-			Convey("Viewer users", func() {
-				query := m.GetFoldersQuery{
-					OrgId:        1,
-					SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER},
-				}
-
-				Convey("Should have no write access to any dashboard folders with default ACL", func() {
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 0)
-				})
-
-				Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() {
-					updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT)
-
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-				})
-			})
-		})
-
 		Convey("Given a plugin with imported dashboards", func() {
 		Convey("Given a plugin with imported dashboards", func() {
 			pluginId := "test-app"
 			pluginId := "test-app"
 
 

+ 2 - 2
pkg/services/sqlstore/dashboard_version_test.go

@@ -12,7 +12,7 @@ import (
 )
 )
 
 
 func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
 func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
-	data["title"] = dashboard.Title
+	data["uid"] = dashboard.Uid
 
 
 	saveCmd := m.SaveDashboardCommand{
 	saveCmd := m.SaveDashboardCommand{
 		OrgId:     dashboard.OrgId,
 		OrgId:     dashboard.OrgId,
@@ -44,7 +44,7 @@ func TestGetDashboardVersion(t *testing.T) {
 
 
 			dashCmd := m.GetDashboardQuery{
 			dashCmd := m.GetDashboardQuery{
 				OrgId: savedDash.OrgId,
 				OrgId: savedDash.OrgId,
-				Slug:  savedDash.Slug,
+				Uid:   savedDash.Uid,
 			}
 			}
 
 
 			err = GetDashboard(&dashCmd)
 			err = GetDashboard(&dashCmd)

+ 91 - 0
pkg/services/sqlstore/login_attempt.go

@@ -0,0 +1,91 @@
+package sqlstore
+
+import (
+	"strconv"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+var getTimeNow = time.Now
+
+func init() {
+	bus.AddHandler("sql", CreateLoginAttempt)
+	bus.AddHandler("sql", DeleteOldLoginAttempts)
+	bus.AddHandler("sql", GetUserLoginAttemptCount)
+}
+
+func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		loginAttempt := m.LoginAttempt{
+			Username:  cmd.Username,
+			IpAddress: cmd.IpAddress,
+			Created:   getTimeNow(),
+		}
+
+		if _, err := sess.Insert(&loginAttempt); err != nil {
+			return err
+		}
+
+		cmd.Result = loginAttempt
+
+		return nil
+	})
+}
+
+func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		var maxId int64
+		sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?")
+		result, err := sess.Query(sql, cmd.OlderThan)
+
+		if err != nil {
+			return err
+		}
+
+		maxId = toInt64(result[0]["id"])
+
+		if maxId == 0 {
+			return nil
+		}
+
+		sql = "DELETE FROM login_attempt WHERE id <= ?"
+
+		if result, err := sess.Exec(sql, maxId); err != nil {
+			return err
+		} else if cmd.DeletedRows, err = result.RowsAffected(); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
+
+func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error {
+	loginAttempt := new(m.LoginAttempt)
+	total, err := x.
+		Where("username = ?", query.Username).
+		And("created >="+dialect.DateTimeFunc("?"), query.Since).
+		Count(loginAttempt)
+
+	if err != nil {
+		return err
+	}
+
+	query.Result = total
+	return nil
+}
+
+func toInt64(i interface{}) int64 {
+	switch i.(type) {
+	case []byte:
+		n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64)
+		return n
+	case int:
+		return int64(i.(int))
+	case int64:
+		return i.(int64)
+	}
+	return 0
+}

+ 125 - 0
pkg/services/sqlstore/login_attempt_test.go

@@ -0,0 +1,125 @@
+package sqlstore
+
+import (
+	"testing"
+	"time"
+
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func mockTime(mock time.Time) time.Time {
+	getTimeNow = func() time.Time { return mock }
+	return mock
+}
+
+func TestLoginAttempts(t *testing.T) {
+	Convey("Testing Login Attempts DB Access", t, func() {
+		InitTestDB(t)
+
+		user := "user"
+		beginningOfTime := mockTime(time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local))
+
+		err := CreateLoginAttempt(&m.CreateLoginAttemptCommand{
+			Username:  user,
+			IpAddress: "192.168.0.1",
+		})
+		So(err, ShouldBeNil)
+
+		timePlusOneMinute := mockTime(beginningOfTime.Add(time.Minute * 1))
+
+		err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{
+			Username:  user,
+			IpAddress: "192.168.0.1",
+		})
+		So(err, ShouldBeNil)
+
+		timePlusTwoMinutes := mockTime(beginningOfTime.Add(time.Minute * 2))
+
+		err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{
+			Username:  user,
+			IpAddress: "192.168.0.1",
+		})
+		So(err, ShouldBeNil)
+
+		Convey("Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", func() {
+			query := m.GetUserLoginAttemptCountQuery{
+				Username: user,
+				Since:    timePlusTwoMinutes.Add(time.Second * 1),
+			}
+			err := GetUserLoginAttemptCount(&query)
+			So(err, ShouldBeNil)
+			So(query.Result, ShouldEqual, 0)
+		})
+
+		Convey("Should return the total count of login attempts since beginning of time", func() {
+			query := m.GetUserLoginAttemptCountQuery{
+				Username: user,
+				Since:    beginningOfTime,
+			}
+			err := GetUserLoginAttemptCount(&query)
+			So(err, ShouldBeNil)
+			So(query.Result, ShouldEqual, 3)
+		})
+
+		Convey("Should return the total count of login attempts since beginning of time + 1min", func() {
+			query := m.GetUserLoginAttemptCountQuery{
+				Username: user,
+				Since:    timePlusOneMinute,
+			}
+			err := GetUserLoginAttemptCount(&query)
+			So(err, ShouldBeNil)
+			So(query.Result, ShouldEqual, 2)
+		})
+
+		Convey("Should return the total count of login attempts since beginning of time + 2min", func() {
+			query := m.GetUserLoginAttemptCountQuery{
+				Username: user,
+				Since:    timePlusTwoMinutes,
+			}
+			err := GetUserLoginAttemptCount(&query)
+			So(err, ShouldBeNil)
+			So(query.Result, ShouldEqual, 1)
+		})
+
+		Convey("Should return deleted rows older than beginning of time", func() {
+			cmd := m.DeleteOldLoginAttemptsCommand{
+				OlderThan: beginningOfTime,
+			}
+			err := DeleteOldLoginAttempts(&cmd)
+
+			So(err, ShouldBeNil)
+			So(cmd.DeletedRows, ShouldEqual, 0)
+		})
+
+		Convey("Should return deleted rows older than beginning of time + 1min", func() {
+			cmd := m.DeleteOldLoginAttemptsCommand{
+				OlderThan: timePlusOneMinute,
+			}
+			err := DeleteOldLoginAttempts(&cmd)
+
+			So(err, ShouldBeNil)
+			So(cmd.DeletedRows, ShouldEqual, 1)
+		})
+
+		Convey("Should return deleted rows older than beginning of time + 2min", func() {
+			cmd := m.DeleteOldLoginAttemptsCommand{
+				OlderThan: timePlusTwoMinutes,
+			}
+			err := DeleteOldLoginAttempts(&cmd)
+
+			So(err, ShouldBeNil)
+			So(cmd.DeletedRows, ShouldEqual, 2)
+		})
+
+		Convey("Should return deleted rows older than beginning of time + 2min and 1s", func() {
+			cmd := m.DeleteOldLoginAttemptsCommand{
+				OlderThan: timePlusTwoMinutes.Add(time.Second * 1),
+			}
+			err := DeleteOldLoginAttempts(&cmd)
+
+			So(err, ShouldBeNil)
+			So(cmd.DeletedRows, ShouldEqual, 3)
+		})
+	})
+}

+ 17 - 0
pkg/services/sqlstore/migrations/dashboard_mig.go

@@ -150,4 +150,21 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{
 	mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{
 		Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0",
 		Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0",
 	}))
 	}))
+
+	mg.AddMigration("Add column uid in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
+	}))
+
+	mg.AddMigration("Update uid column values in dashboard", new(RawSqlMigration).
+		Sqlite("UPDATE dashboard SET uid=printf('%09d',id) WHERE uid IS NULL;").
+		Postgres("UPDATE dashboard SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;").
+		Mysql("UPDATE dashboard SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))
+
+	mg.AddMigration("Add unique index dashboard_org_id_uid", NewAddIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "uid"}, Type: UniqueIndex,
+	}))
+
+	mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
+	}))
 }
 }

+ 23 - 0
pkg/services/sqlstore/migrations/login_attempt_mig.go

@@ -0,0 +1,23 @@
+package migrations
+
+import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+
+func addLoginAttemptMigrations(mg *Migrator) {
+	loginAttemptV1 := Table{
+		Name: "login_attempt",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false},
+			{Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false},
+			{Name: "created", Type: DB_DateTime, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"username"}},
+		},
+	}
+
+	// create table
+	mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1))
+	// add indices
+	mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0]))
+}

+ 1 - 0
pkg/services/sqlstore/migrations/migrations.go

@@ -29,6 +29,7 @@ func AddMigrations(mg *Migrator) {
 	addTeamMigrations(mg)
 	addTeamMigrations(mg)
 	addDashboardAclMigrations(mg)
 	addDashboardAclMigrations(mg)
 	addTagMigration(mg)
 	addTagMigration(mg)
+	addLoginAttemptMigrations(mg)
 }
 }
 
 
 func addMigrationLogMigrations(mg *Migrator) {
 func addMigrationLogMigrations(mg *Migrator) {

+ 5 - 0
pkg/services/sqlstore/migrator/dialect.go

@@ -19,6 +19,7 @@ type Dialect interface {
 	LikeStr() string
 	LikeStr() string
 	Default(col *Column) string
 	Default(col *Column) string
 	BooleanStr(bool) string
 	BooleanStr(bool) string
+	DateTimeFunc(string) string
 
 
 	CreateIndexSql(tableName string, index *Index) string
 	CreateIndexSql(tableName string, index *Index) string
 	CreateTableSql(table *Table) string
 	CreateTableSql(table *Table) string
@@ -78,6 +79,10 @@ func (b *BaseDialect) Default(col *Column) string {
 	return col.Default
 	return col.Default
 }
 }
 
 
+func (db *BaseDialect) DateTimeFunc(value string) string {
+	return value
+}
+
 func (b *BaseDialect) CreateTableSql(table *Table) string {
 func (b *BaseDialect) CreateTableSql(table *Table) string {
 	var sql string
 	var sql string
 	sql = "CREATE TABLE IF NOT EXISTS "
 	sql = "CREATE TABLE IF NOT EXISTS "

+ 4 - 0
pkg/services/sqlstore/migrator/sqlite_dialect.go

@@ -36,6 +36,10 @@ func (db *Sqlite3) BooleanStr(value bool) string {
 	return "0"
 	return "0"
 }
 }
 
 
+func (db *Sqlite3) DateTimeFunc(value string) string {
+	return "datetime(" + value + ")"
+}
+
 func (db *Sqlite3) SqlType(c *Column) string {
 func (db *Sqlite3) SqlType(c *Column) string {
 	switch c.Type {
 	switch c.Type {
 	case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time:
 	case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time:

+ 1 - 0
pkg/services/sqlstore/search_builder.go

@@ -101,6 +101,7 @@ func (sb *SearchBuilder) buildSelect() {
 	sb.sql.WriteString(
 	sb.sql.WriteString(
 		`SELECT
 		`SELECT
 			dashboard.id,
 			dashboard.id,
+			dashboard.uid,
 			dashboard.title,
 			dashboard.title,
 			dashboard.slug,
 			dashboard.slug,
 			dashboard_tag.term,
 			dashboard_tag.term,

+ 1 - 1
pkg/services/sqlstore/sqlutil/sqlutil.go

@@ -12,7 +12,7 @@ type TestDB struct {
 }
 }
 
 
 var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"}
 var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"}
-var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"}
+var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci&loc=Local"}
 var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"}
 var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"}
 
 
 func CleanDB(x *xorm.Engine) {
 func CleanDB(x *xorm.Engine) {

+ 10 - 8
pkg/setting/setting.go

@@ -75,13 +75,14 @@ var (
 	EnforceDomain      bool
 	EnforceDomain      bool
 
 
 	// Security settings.
 	// Security settings.
-	SecretKey             string
-	LogInRememberDays     int
-	CookieUserName        string
-	CookieRememberName    string
-	DisableGravatar       bool
-	EmailCodeValidMinutes int
-	DataProxyWhiteList    map[string]bool
+	SecretKey                        string
+	LogInRememberDays                int
+	CookieUserName                   string
+	CookieRememberName               string
+	DisableGravatar                  bool
+	EmailCodeValidMinutes            int
+	DataProxyWhiteList               map[string]bool
+	DisableBruteForceLoginProtection bool
 
 
 	// Snapshots
 	// Snapshots
 	ExternalSnapshotUrl   string
 	ExternalSnapshotUrl   string
@@ -514,6 +515,7 @@ func NewConfigContext(args *CommandLineArgs) error {
 	CookieUserName = security.Key("cookie_username").String()
 	CookieUserName = security.Key("cookie_username").String()
 	CookieRememberName = security.Key("cookie_remember_name").String()
 	CookieRememberName = security.Key("cookie_remember_name").String()
 	DisableGravatar = security.Key("disable_gravatar").MustBool(true)
 	DisableGravatar = security.Key("disable_gravatar").MustBool(true)
+	DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
 
 
 	// read snapshots settings
 	// read snapshots settings
 	snapshots := Cfg.Section("snapshots")
 	snapshots := Cfg.Section("snapshots")
@@ -578,7 +580,7 @@ func NewConfigContext(args *CommandLineArgs) error {
 
 
 	// PhantomJS rendering
 	// PhantomJS rendering
 	ImagesDir = filepath.Join(DataPath, "png")
 	ImagesDir = filepath.Join(DataPath, "png")
-	PhantomDir = filepath.Join(HomePath, "vendor/phantomjs")
+	PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
 
 
 	analytics := Cfg.Section("analytics")
 	analytics := Cfg.Section("analytics")
 	ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)
 	ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)

+ 15 - 0
pkg/util/shortid_generator.go

@@ -0,0 +1,15 @@
+package util
+
+import (
+	"github.com/teris-io/shortid"
+)
+
+func init() {
+	gen, _ := shortid.New(1, shortid.DefaultABC, 1)
+	shortid.SetDefault(gen)
+}
+
+// GenerateShortUid generates a short unique identifier.
+func GenerateShortUid() string {
+	return shortid.MustGenerate()
+}

+ 2 - 1
public/app/containers/AlertRuleList/AlertRuleList.jest.tsx

@@ -23,7 +23,8 @@ describe('AlertRuleList', () => {
             .format(),
             .format(),
           evalData: {},
           evalData: {},
           executionError: '',
           executionError: '',
-          dashboardUri: 'db/mygool',
+          dashboardUri: 'd/ufkcofof/my-goal',
+          canEdit: true,
         },
         },
       ])
       ])
     );
     );

+ 21 - 7
public/app/containers/AlertRuleList/AlertRuleList.tsx

@@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
       'fa-pause': !rule.isPaused,
       'fa-pause': !rule.isPaused,
     });
     });
 
 
-    let ruleUrl = `dashboard/${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`;
+    let ruleUrl = `${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
 
 
     return (
     return (
       <li className="alert-rule-item">
       <li className="alert-rule-item">
@@ -147,7 +147,8 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
         <div className="alert-rule-item__body">
         <div className="alert-rule-item__body">
           <div className="alert-rule-item__header">
           <div className="alert-rule-item__header">
             <div className="alert-rule-item__name">
             <div className="alert-rule-item__name">
-              <a href={ruleUrl}>{this.renderText(rule.name)}</a>
+              {rule.canEdit && <a href={ruleUrl}>{this.renderText(rule.name)}</a>}
+              {!rule.canEdit && <span>{this.renderText(rule.name)}</span>}
             </div>
             </div>
             <div className="alert-rule-item__text">
             <div className="alert-rule-item__text">
               <span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
               <span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
@@ -156,17 +157,30 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
           </div>
           </div>
           {rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
           {rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
         </div>
         </div>
+
         <div className="alert-rule-item__actions">
         <div className="alert-rule-item__actions">
-          <a
+          <button
             className="btn btn-small btn-inverse alert-list__btn width-2"
             className="btn btn-small btn-inverse alert-list__btn width-2"
             title="Pausing an alert rule prevents it from executing"
             title="Pausing an alert rule prevents it from executing"
             onClick={this.toggleState}
             onClick={this.toggleState}
+            disabled={!rule.canEdit}
           >
           >
             <i className={stateClass} />
             <i className={stateClass} />
-          </a>
-          <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
-            <i className="icon-gf icon-gf-settings" />
-          </a>
+          </button>
+          {rule.canEdit && (
+            <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
+              <i className="icon-gf icon-gf-settings" />
+            </a>
+          )}
+          {!rule.canEdit && (
+            <button
+              className="btn btn-small btn-inverse alert-list__btn width-2"
+              title="Edit alert rule"
+              disabled={true}
+            >
+              <i className="icon-gf icon-gf-settings" />
+            </button>
+          )}
         </div>
         </div>
       </li>
       </li>
     );
     );

+ 5 - 4
public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap

@@ -21,7 +21,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
         className="alert-rule-item__name"
         className="alert-rule-item__name"
       >
       >
         <a
         <a
-          href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
+          href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
         >
         >
           <Highlighter
           <Highlighter
             highlightClassName="highlight-search-match"
             highlightClassName="highlight-search-match"
@@ -80,18 +80,19 @@ exports[`AlertRuleList should render 1 rule 1`] = `
   <div
   <div
     className="alert-rule-item__actions"
     className="alert-rule-item__actions"
   >
   >
-    <a
+    <button
       className="btn btn-small btn-inverse alert-list__btn width-2"
       className="btn btn-small btn-inverse alert-list__btn width-2"
+      disabled={false}
       onClick={[Function]}
       onClick={[Function]}
       title="Pausing an alert rule prevents it from executing"
       title="Pausing an alert rule prevents it from executing"
     >
     >
       <i
       <i
         className="fa fa-pause"
         className="fa fa-pause"
       />
       />
-    </a>
+    </button>
     <a
     <a
       className="btn btn-small btn-inverse alert-list__btn width-2"
       className="btn btn-small btn-inverse alert-list__btn width-2"
-      href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
+      href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
       title="Edit alert rule"
       title="Edit alert rule"
     >
     >
       <i
       <i

+ 22 - 3
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -6,21 +6,30 @@ import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+import AddPermissions from 'app/core/components/Permissions/AddPermissions';
+import SlideDown from 'app/core/components/Animations/SlideDown';
 @inject('nav', 'folder', 'view', 'permissions')
 @inject('nav', 'folder', 'view', 'permissions')
 @observer
 @observer
 export class FolderPermissions extends Component<IContainerProps, any> {
 export class FolderPermissions extends Component<IContainerProps, any> {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.loadStore();
     this.loadStore();
   }
   }
 
 
   loadStore() {
   loadStore() {
     const { nav, folder, view } = this.props;
     const { nav, folder, view } = this.props;
-    return folder.load(view.routeParams.get('slug') as string).then(res => {
+    return folder.load(view.routeParams.get('uid') as string).then(res => {
+      view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {});
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
     });
     });
   }
   }
 
 
+  handleAddPermission() {
+    const { permissions } = this.props;
+    permissions.toggleAddPermissions();
+  }
+
   render() {
   render() {
     const { nav, folder, permissions, backendSrv } = this.props;
     const { nav, folder, permissions, backendSrv } = this.props;
 
 
@@ -34,13 +43,23 @@ export class FolderPermissions extends Component<IContainerProps, any> {
       <div>
       <div>
         <PageHeader model={nav as any} />
         <PageHeader model={nav as any} />
         <div className="page-container page-body">
         <div className="page-container page-body">
-          <div className="page-sub-heading">
+          <div className="page-action-bar">
             <h2 className="d-inline-block">Folder Permissions</h2>
             <h2 className="d-inline-block">Folder Permissions</h2>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
               <i className="gicon gicon-question gicon--has-hover" />
               <i className="gicon gicon-question gicon--has-hover" />
             </Tooltip>
             </Tooltip>
+            <div className="page-action-bar__spacer" />
+            <button
+              className="btn btn-success pull-right"
+              onClick={this.handleAddPermission}
+              disabled={permissions.isAddPermissionsVisible}
+            >
+              <i className="fa fa-plus" /> Add Permission
+            </button>
           </div>
           </div>
-
+          <SlideDown in={permissions.isAddPermissionsVisible}>
+            <AddPermissions permissions={permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
+          </SlideDown>
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
         </div>
         </div>
       </div>
       </div>

+ 2 - 2
public/app/containers/ManageDashboards/FolderSettings.jest.tsx

@@ -9,14 +9,14 @@ describe('FolderSettings', () => {
   let page;
   let page;
 
 
   beforeAll(() => {
   beforeAll(() => {
-    backendSrv.getDashboard.mockReturnValue(
+    backendSrv.getDashboardByUid.mockReturnValue(
       Promise.resolve({
       Promise.resolve({
         dashboard: {
         dashboard: {
           id: 1,
           id: 1,
           title: 'Folder Name',
           title: 'Folder Name',
         },
         },
         meta: {
         meta: {
-          slug: 'folder-name',
+          url: '/dashboards/f/uid/folder-name',
           canSave: true,
           canSave: true,
         },
         },
       })
       })

+ 4 - 2
public/app/containers/ManageDashboards/FolderSettings.tsx

@@ -20,10 +20,12 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
   loadStore() {
   loadStore() {
     const { nav, folder, view } = this.props;
     const { nav, folder, view } = this.props;
 
 
-    return folder.load(view.routeParams.get('slug') as string).then(res => {
+    return folder.load(view.routeParams.get('uid') as string).then(res => {
       this.formSnapshot = getSnapshot(folder);
       this.formSnapshot = getSnapshot(folder);
       this.dashboard = res.dashboard;
       this.dashboard = res.dashboard;
 
 
+      view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {});
+
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
     });
     });
   }
   }
@@ -51,7 +53,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     folder
     folder
       .saveFolder(this.dashboard, { overwrite: false })
       .saveFolder(this.dashboard, { overwrite: false })
       .then(newUrl => {
       .then(newUrl => {
-        view.updatePathAndQuery(newUrl, '', '');
+        view.updatePathAndQuery(newUrl, {}, {});
 
 
         appEvents.emit('dashboard-saved');
         appEvents.emit('dashboard-saved');
         appEvents.emit('alert-success', ['Folder saved']);
         appEvents.emit('alert-success', ['Folder saved']);

+ 37 - 0
public/app/core/components/Animations/SlideDown.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import Transition from 'react-transition-group/Transition';
+
+const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
+// If this is not enough, pass in <SlideDown maxHeight="....
+const defaultDuration = 200;
+const defaultStyle = {
+  transition: `max-height ${defaultDuration}ms ease-in-out`,
+  overflow: 'hidden',
+};
+
+export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
+  // There are 4 main states a Transition can be in:
+  // ENTERING, ENTERED, EXITING, EXITED
+  // https://reactcommunity.org/react-transition-group/
+  const transitionStyles = {
+    exited: { maxHeight: 0 },
+    entering: { maxHeight: maxHeight },
+    entered: { maxHeight: maxHeight, overflow: 'visible' },
+    exiting: { maxHeight: 0 },
+  };
+
+  return (
+    <Transition in={inProp} timeout={defaultDuration}>
+      {state => (
+        <div
+          style={{
+            ...defaultStyle,
+            ...transitionStyles[state],
+          }}
+        >
+          {children}
+        </div>
+      )}
+    </Transition>
+  );
+};

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

@@ -0,0 +1,90 @@
+import React from 'react';
+import AddPermissions from './AddPermissions';
+import { RootStore } from 'app/stores/RootStore/RootStore';
+import { backendSrv } from 'test/mocks/common';
+import { shallow } from 'enzyme';
+
+describe('AddPermissions', () => {
+  let wrapper;
+  let store;
+  let instance;
+
+  beforeAll(() => {
+    backendSrv.get.mockReturnValue(
+      Promise.resolve([
+        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
+        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
+      ])
+    );
+
+    backendSrv.post = jest.fn();
+
+    store = RootStore.create(
+      {},
+      {
+        backendSrv: backendSrv,
+      }
+    );
+
+    wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} dashboardId={1} />);
+    instance = wrapper.instance();
+    return store.permissions.load(1, true, false);
+  });
+
+  describe('when permission for a user is added', () => {
+    it('should save permission to db', () => {
+      const evt = {
+        target: {
+          value: 'User',
+        },
+      };
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+
+      instance.typeChanged(evt);
+      instance.userPicked(userItem);
+
+      wrapper.update();
+
+      expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
+
+      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');
+    });
+  });
+
+  describe('when permission for team is added', () => {
+    it('should save permission to db', () => {
+      const evt = {
+        target: {
+          value: 'Group',
+        },
+      };
+
+      const teamItem = {
+        id: 2,
+        name: 'ug1',
+      };
+
+      instance.typeChanged(evt);
+      instance.teamPicked(teamItem);
+
+      wrapper.update();
+
+      expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
+
+      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');
+    });
+  });
+
+  afterEach(() => {
+    backendSrv.post.mockClear();
+  });
+});

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

@@ -0,0 +1,158 @@
+import React, { Component } from 'react';
+import { observer } from 'mobx-react';
+import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
+import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
+import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
+import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
+import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
+
+export interface IProps {
+  permissions: any;
+  backendSrv: any;
+  dashboardId: any;
+}
+@observer
+class AddPermissions extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.userPicked = this.userPicked.bind(this);
+    this.teamPicked = this.teamPicked.bind(this);
+    this.permissionPicked = this.permissionPicked.bind(this);
+    this.typeChanged = this.typeChanged.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  componentWillMount() {
+    const { permissions } = this.props;
+    permissions.resetNewType();
+  }
+
+  typeChanged(evt) {
+    const { value } = evt.target;
+    const { permissions } = this.props;
+
+    // if (value === 'Viewer' || value === 'Editor') {
+    // //   permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
+    // //   this.resetNewType();
+    //   return;
+    // }
+
+    permissions.setNewType(value);
+  }
+
+  userPicked(user: User) {
+    const { permissions } = this.props;
+    if (!user) {
+      permissions.newItem.setUser(null, null);
+      return;
+    }
+    return permissions.newItem.setUser(user.id, user.login);
+  }
+
+  teamPicked(team: Team) {
+    const { permissions } = this.props;
+    if (!team) {
+      permissions.newItem.setTeam(null, null);
+      return;
+    }
+    return permissions.newItem.setTeam(team.id, team.name);
+  }
+
+  permissionPicked(permission: OptionWithDescription) {
+    const { permissions } = this.props;
+    return permissions.newItem.setPermission(permission.value);
+  }
+
+  resetNewType() {
+    const { permissions } = this.props;
+    return permissions.resetNewType();
+  }
+
+  handleSubmit(evt) {
+    evt.preventDefault();
+    const { permissions } = this.props;
+    permissions.addStoreItem();
+  }
+
+  render() {
+    const { permissions, backendSrv } = this.props;
+    const newItem = permissions.newItem;
+    const pickerClassName = 'width-20';
+
+    const isValid = newItem.isValid();
+
+    return (
+      <div className="gf-form-inline cta-form">
+        <button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
+          <i className="fa fa-close" />
+        </button>
+        <form name="addPermission" onSubmit={this.handleSubmit}>
+          <h6>Add Permission For</h6>
+          <div className="gf-form-inline">
+            <div className="gf-form">
+              <div className="gf-form-select-wrapper">
+                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
+                  {aclTypes.map((option, idx) => {
+                    return (
+                      <option key={idx} value={option.value}>
+                        {option.text}
+                      </option>
+                    );
+                  })}
+                </select>
+              </div>
+            </div>
+
+            {newItem.type === 'User' ? (
+              <div className="gf-form">
+                <UserPicker
+                  backendSrv={backendSrv}
+                  handlePicked={this.userPicked}
+                  value={newItem.userId}
+                  className={pickerClassName}
+                />
+              </div>
+            ) : null}
+
+            {newItem.type === 'Group' ? (
+              <div className="gf-form">
+                <TeamPicker
+                  backendSrv={backendSrv}
+                  handlePicked={this.teamPicked}
+                  value={newItem.teamId}
+                  className={pickerClassName}
+                />
+              </div>
+            ) : null}
+
+            <div className="gf-form">
+              <DescriptionPicker
+                optionsWithDesc={permissionOptions}
+                handlePicked={this.permissionPicked}
+                value={newItem.permission}
+                disabled={false}
+                className={'gf-form-input--form-dropdown-right'}
+              />
+            </div>
+
+            <div className="gf-form">
+              <button data-save-permission className="btn btn-success" type="submit" disabled={!isValid}>
+                Save
+              </button>
+            </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>
+    );
+  }
+}
+
+export default AddPermissions;

+ 27 - 6
public/app/core/components/Permissions/DashboardPermissions.tsx

@@ -1,8 +1,11 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
+import { observer } from 'mobx-react';
 import { store } from 'app/stores/store';
 import { store } from 'app/stores/store';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+import AddPermissions from 'app/core/components/Permissions/AddPermissions';
+import SlideDown from 'app/core/components/Animations/SlideDown';
 
 
 export interface IProps {
 export interface IProps {
   dashboardId: number;
   dashboardId: number;
@@ -11,31 +14,49 @@ export interface IProps {
   folderSlug: string;
   folderSlug: string;
   backendSrv: any;
   backendSrv: any;
 }
 }
-
+@observer
 class DashboardPermissions extends Component<IProps, any> {
 class DashboardPermissions extends Component<IProps, any> {
   permissions: any;
   permissions: any;
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.permissions = store.permissions;
     this.permissions = store.permissions;
   }
   }
 
 
+  handleAddPermission() {
+    this.permissions.toggleAddPermissions();
+  }
+
   render() {
   render() {
     const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
     const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
 
 
     return (
     return (
       <div>
       <div>
         <div className="dashboard-settings__header">
         <div className="dashboard-settings__header">
-          <h3 className="d-inline-block">Permissions</h3>
-          <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
-            <i className="gicon gicon-question gicon--has-hover" />
-          </Tooltip>
+          <div className="page-action-bar">
+            <h3 className="d-inline-block">Permissions</h3>
+            <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
+              <i className="gicon gicon-question gicon--has-hover" />
+            </Tooltip>
+            <div className="page-action-bar__spacer" />
+            <button
+              className="btn btn-success pull-right"
+              onClick={this.handleAddPermission}
+              disabled={this.permissions.isAddPermissionsVisible}
+            >
+              <i className="fa fa-plus" /> Add Permission
+            </button>
+          </div>
         </div>
         </div>
+        <SlideDown in={this.permissions.isAddPermissionsVisible}>
+          <AddPermissions permissions={this.permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
+        </SlideDown>
         <Permissions
         <Permissions
           permissions={this.permissions}
           permissions={this.permissions}
           isFolder={false}
           isFolder={false}
           dashboardId={dashboardId}
           dashboardId={dashboardId}
-           folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
+          folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
           backendSrv={backendSrv}
           backendSrv={backendSrv}
         />
         />
       </div>
       </div>

+ 0 - 73
public/app/core/components/Permissions/Permissions.jest.tsx

@@ -1,73 +0,0 @@
-import React from 'react';
-import Permissions from './Permissions';
-import { RootStore } from 'app/stores/RootStore/RootStore';
-import { backendSrv } from 'test/mocks/common';
-import { shallow } from 'enzyme';
-
-describe('Permissions', () => {
-  let wrapper;
-
-  beforeAll(() => {
-    backendSrv.get.mockReturnValue(
-      Promise.resolve([
-        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
-        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
-        {
-          id: 4,
-          dashboardId: 1,
-          userId: 2,
-          userLogin: 'danlimerick',
-          userEmail: 'dan.limerick@gmail.com',
-          permission: 4,
-          permissionName: 'Admin',
-        },
-      ])
-    );
-
-    backendSrv.post = jest.fn();
-
-    const store = RootStore.create(
-      {},
-      {
-        backendSrv: backendSrv,
-      }
-    );
-
-    wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
-    return wrapper.instance().loadStore(1, true);
-  });
-
-  describe('when permission for a user is added', () => {
-    it('should save permission to db', () => {
-      const userItem = {
-        id: 2,
-        login: 'user2',
-      };
-
-      wrapper
-        .instance()
-        .userPicked(userItem)
-        .then(() => {
-          expect(backendSrv.post.mock.calls.length).toBe(1);
-          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-        });
-    });
-  });
-
-  describe('when permission for team is added', () => {
-    it('should save permission to db', () => {
-      const teamItem = {
-        id: 2,
-        name: 'ug1',
-      };
-
-      wrapper
-        .instance()
-        .teamPicked(teamItem)
-        .then(() => {
-          expect(backendSrv.post.mock.calls.length).toBe(1);
-          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-        });
-    });
-  });
-});

+ 6 - 65
public/app/core/components/Permissions/Permissions.tsx

@@ -1,9 +1,6 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import PermissionsList from './PermissionsList';
 import PermissionsList from './PermissionsList';
 import { observer } from 'mobx-react';
 import { observer } from 'mobx-react';
-import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
-import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
-import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
 import { FolderInfo } from './FolderInfo';
 import { FolderInfo } from './FolderInfo';
 
 
 export interface DashboardAcl {
 export interface DashboardAcl {
@@ -36,17 +33,15 @@ export interface IProps {
 class Permissions extends Component<IProps, any> {
 class Permissions extends Component<IProps, any> {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-    const { dashboardId, isFolder } = this.props;
+    const { dashboardId, isFolder, folderInfo } = this.props;
     this.permissionChanged = this.permissionChanged.bind(this);
     this.permissionChanged = this.permissionChanged.bind(this);
     this.typeChanged = this.typeChanged.bind(this);
     this.typeChanged = this.typeChanged.bind(this);
     this.removeItem = this.removeItem.bind(this);
     this.removeItem = this.removeItem.bind(this);
-    this.userPicked = this.userPicked.bind(this);
-    this.teamPicked = this.teamPicked.bind(this);
-    this.loadStore(dashboardId, isFolder);
+    this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
   }
   }
 
 
-  loadStore(dashboardId, isFolder) {
-    return this.props.permissions.load(dashboardId, isFolder);
+  loadStore(dashboardId, isFolder, isInRoot = false) {
+    return this.props.permissions.load(dashboardId, isFolder, isInRoot);
   }
   }
 
 
   permissionChanged(index: number, permission: number, permissionName: string) {
   permissionChanged(index: number, permission: number, permissionName: string) {
@@ -77,18 +72,8 @@ class Permissions extends Component<IProps, any> {
     permissions.setNewType(value);
     permissions.setNewType(value);
   }
   }
 
 
-  userPicked(user: User) {
-    const { permissions } = this.props;
-    return permissions.addStoreItem({ userId: user.id, userLogin: user.login, permission: 1 });
-  }
-
-  teamPicked(team: Team) {
-    const { permissions } = this.props;
-    return permissions.addStoreItem({ teamId: team.id, team: team.name, permission: 1 });
-  }
-
   render() {
   render() {
-    const { permissions, folderInfo, backendSrv } = this.props;
+    const { permissions, folderInfo } = this.props;
 
 
     return (
     return (
       <div className="gf-form-group">
       <div className="gf-form-group">
@@ -99,50 +84,6 @@ class Permissions extends Component<IProps, any> {
           fetching={permissions.fetching}
           fetching={permissions.fetching}
           folderInfo={folderInfo}
           folderInfo={folderInfo}
         />
         />
-        <div className="gf-form-inline">
-          <form name="addPermission" className="gf-form-group">
-            <h6 className="muted">Add Permission For</h6>
-            <div className="gf-form-inline">
-              <div className="gf-form">
-                <div className="gf-form-select-wrapper">
-                  <select
-                    className="gf-form-input gf-size-auto"
-                    value={permissions.newType}
-                    onChange={this.typeChanged}
-                  >
-                    {aclTypes.map((option, idx) => {
-                      return (
-                        <option key={idx} value={option.value}>
-                          {option.text}
-                        </option>
-                      );
-                    })}
-                  </select>
-                </div>
-              </div>
-
-              {permissions.newType === 'User' ? (
-                <div className="gf-form">
-                  <UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
-                </div>
-              ) : null}
-
-              {permissions.newType === 'Group' ? (
-                <div className="gf-form">
-                  <TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
-                </div>
-              ) : null}
-            </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>
       </div>
       </div>
     );
     );
   }
   }

+ 12 - 8
public/app/core/components/Permissions/PermissionsListItem.tsx

@@ -17,6 +17,8 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
     permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
     permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
   };
   };
 
 
+  const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
+
   return (
   return (
     <tr className={setClassNameHelper(item.inherited)}>
     <tr className={setClassNameHelper(item.inherited)}>
       <td style={{ width: '100%' }}>
       <td style={{ width: '100%' }}>
@@ -24,14 +26,16 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
         <span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
         <span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
       </td>
       </td>
       <td>
       <td>
-        {item.inherited && folderInfo ? (
-          <em className="muted no-wrap">
-            Inherited from folder{' '}
-            <a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
-              {folderInfo.title}
-            </a>{' '}
-          </em>
-        ) : null}
+        {item.inherited &&
+          folderInfo && (
+            <em className="muted no-wrap">
+              Inherited from folder{' '}
+              <a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
+                {folderInfo.title}
+              </a>{' '}
+            </em>
+          )}
+        {inheritedFromRoot && <em className="muted no-wrap">Default Permission</em>}
       </td>
       </td>
       <td className="query-keyword">Can</td>
       <td className="query-keyword">Can</td>
       <td>
       <td>

+ 2 - 8
public/app/core/components/Picker/DescriptionOption.tsx

@@ -41,18 +41,12 @@ class DescriptionOption extends Component<IProps, any> {
         onMouseEnter={this.handleMouseEnter}
         onMouseEnter={this.handleMouseEnter}
         onMouseMove={this.handleMouseMove}
         onMouseMove={this.handleMouseMove}
         title={option.title}
         title={option.title}
-        className={`user-picker-option__button btn btn-link ${className} width-19`}
-        style={{
-          whiteSpace: 'normal',
-          // height: '55px',
-        }}
+        className={`description-picker-option__button btn btn-link ${className} width-19`}
       >
       >
         <div className="gf-form">{children}</div>
         <div className="gf-form">{children}</div>
         <div className="gf-form">
         <div className="gf-form">
           <div className="muted width-17">{option.description}</div>
           <div className="muted width-17">{option.description}</div>
-          {className.indexOf('is-selected') > -1 && (
-            <i style={{ paddingLeft: '2px' }} className="fa fa-check" aria-hidden="true" />
-          )}
+          {className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
         </div>
         </div>
       </button>
       </button>
     );
     );

+ 19 - 0
public/app/core/components/Picker/TeamPicker.jest.tsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import TeamPicker from './TeamPicker';
+
+const model = {
+  backendSrv: {
+    get: () => {
+      return new Promise((resolve, reject) => {});
+    },
+  },
+  handlePicked: () => {},
+};
+
+describe('TeamPicker', () => {
+  it('renders correctly', () => {
+    const tree = renderer.create(<TeamPicker {...model} />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });
+});

+ 7 - 2
public/app/core/components/Picker/TeamPicker.tsx

@@ -9,6 +9,8 @@ export interface IProps {
   isLoading: boolean;
   isLoading: boolean;
   toggleLoading: any;
   toggleLoading: any;
   handlePicked: (user) => void;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 }
 
 
 export interface Team {
 export interface Team {
@@ -54,7 +56,7 @@ class TeamPicker extends Component<IProps, any> {
 
 
   render() {
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
+    const { isLoading, handlePicked, value, className } = this.props;
 
 
     return (
     return (
       <div className="user-picker">
       <div className="user-picker">
@@ -66,10 +68,13 @@ class TeamPicker extends Component<IProps, any> {
           isLoading={isLoading}
           isLoading={isLoading}
           loadOptions={this.debouncedSearch}
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
           loadingPlaceholder="Loading..."
+          noResultsText="No teams found"
           onChange={handlePicked}
           onChange={handlePicked}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
           optionComponent={PickerOption}
           placeholder="Choose"
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
         />
       </div>
       </div>
     );
     );

+ 6 - 3
public/app/core/components/Picker/UserPicker.tsx

@@ -9,6 +9,8 @@ export interface IProps {
   isLoading: boolean;
   isLoading: boolean;
   toggleLoading: any;
   toggleLoading: any;
   handlePicked: (user) => void;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 }
 
 
 export interface User {
 export interface User {
@@ -53,8 +55,7 @@ class UserPicker extends Component<IProps, any> {
 
 
   render() {
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
-
+    const { isLoading, handlePicked, value, className } = this.props;
     return (
     return (
       <div className="user-picker">
       <div className="user-picker">
         <AsyncComponent
         <AsyncComponent
@@ -67,9 +68,11 @@ class UserPicker extends Component<IProps, any> {
           loadingPlaceholder="Loading..."
           loadingPlaceholder="Loading..."
           noResultsText="No users found"
           noResultsText="No users found"
           onChange={handlePicked}
           onChange={handlePicked}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
           optionComponent={PickerOption}
           placeholder="Choose"
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
         />
       </div>
       </div>
     );
     );

+ 98 - 0
public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap

@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TeamPicker renders correctly 1`] = `
+<div
+  className="user-picker"
+>
+  <div
+    className="Select gf-form-input gf-form-input--form-dropdown  is-clearable is-loading is-searchable Select--single"
+    style={undefined}
+  >
+    <div
+      className="Select-control"
+      onKeyDown={[Function]}
+      onMouseDown={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      style={undefined}
+    >
+      <span
+        className="Select-multi-value-wrapper"
+        id="react-select-2--value"
+      >
+        <div
+          className="Select-placeholder"
+        >
+          Loading...
+        </div>
+        <div
+          className="Select-input"
+          style={
+            Object {
+              "display": "inline-block",
+            }
+          }
+        >
+          <input
+            aria-activedescendant="react-select-2--value"
+            aria-describedby={undefined}
+            aria-expanded="false"
+            aria-haspopup="false"
+            aria-label={undefined}
+            aria-labelledby={undefined}
+            aria-owns=""
+            className={undefined}
+            id={undefined}
+            onBlur={[Function]}
+            onChange={[Function]}
+            onFocus={[Function]}
+            required={false}
+            role="combobox"
+            style={
+              Object {
+                "boxSizing": "content-box",
+                "width": "5px",
+              }
+            }
+            tabIndex={undefined}
+            value=""
+          />
+          <div
+            style={
+              Object {
+                "height": 0,
+                "left": 0,
+                "overflow": "scroll",
+                "position": "absolute",
+                "top": 0,
+                "visibility": "hidden",
+                "whiteSpace": "pre",
+              }
+            }
+          >
+            
+          </div>
+        </div>
+      </span>
+      <span
+        aria-hidden="true"
+        className="Select-loading-zone"
+      >
+        <span
+          className="Select-loading"
+        />
+      </span>
+      <span
+        className="Select-arrow-zone"
+        onMouseDown={[Function]}
+      >
+        <span
+          className="Select-arrow"
+          onMouseDown={[Function]}
+        />
+      </span>
+    </div>
+  </div>
+</div>
+`;

Some files were not shown because too many files changed in this diff