Browse Source

Merge branch 'master' into backend_plugins

* master: (584 commits)
  prometheus: change default resolution to 1/1
  fix: viewers can edit now works correctly
  fix: fixed minor ux and firefox issues, fixes #10228
  ux: minor fixes
  profile: use name or fallback for profile page
  fix: sidemenu profile main text is now username instead of name
  build: update master version to 5.0.0-pre1
  dashfolder: change to migration text
  ux:s sidemenu icon rules
  teams: add team count when searching for team
  changed background color for infobox and new blues in light theme, light theme now uses blue-dark in panel query (#10211)
  ux: fixed navbar issue when sidemenu closes
  ux: minor position change for layout selector, fixes #10217
  fix: view json from share modal now works, #10217
  ux: used new add data sources icon
  dashfolders: styling of selected filters
  dashfolders: styling of selected filters
  dashfolders: fix moving plugin dashboard to folder
  changelog: adds note about closing #9170
  dashfolders: fix folder selection dropdown in dashboard settings
  ...
bergquist 8 years ago
parent
commit
8573f73d72
100 changed files with 2963 additions and 587 deletions
  1. 3 0
      .gitignore
  2. 24 14
      CHANGELOG.md
  3. 2 2
      README.md
  4. 1 1
      ROADMAP.md
  5. 3 0
      conf/defaults.ini
  6. 3 0
      conf/sample.ini
  7. 1 1
      docker/blocks/graphite/docker-compose.yaml
  8. 18 17
      docs/sources/alerting/notifications.md
  9. 2 0
      docs/sources/http_api/alerting.md
  10. 1 1
      docs/sources/http_api/auth.md
  11. 11 7
      docs/sources/installation/configuration.md
  12. 3 16
      docs/sources/installation/debian.md
  13. 5 7
      docs/sources/installation/rpm.md
  14. 1 1
      docs/sources/installation/windows.md
  15. 1 1
      jest.config.js
  16. 14 10
      package.json
  17. 1 1
      packaging/publish/publish_both.sh
  18. 30 7
      pkg/api/api.go
  19. 8 4
      pkg/api/avatar/avatar.go
  20. 127 72
      pkg/api/dashboard.go
  21. 79 0
      pkg/api/dashboard_acl.go
  22. 174 0
      pkg/api/dashboard_acl_test.go
  23. 521 0
      pkg/api/dashboard_test.go
  24. 11 2
      pkg/api/datasources_test.go
  25. 16 0
      pkg/api/dtos/acl.go
  26. 19 14
      pkg/api/dtos/dashboard.go
  27. 14 7
      pkg/api/dtos/index.go
  28. 1 1
      pkg/api/dtos/invite.go
  29. 1 0
      pkg/api/dtos/models.go
  30. 0 1
      pkg/api/frontendsettings.go
  31. 145 40
      pkg/api/index.go
  32. 2 2
      pkg/api/org_invite.go
  33. 5 4
      pkg/api/org_users.go
  34. 1 1
      pkg/api/playlist.go
  35. 9 9
      pkg/api/playlist_play.go
  36. 17 2
      pkg/api/pluginproxy/ds_proxy.go
  37. 52 0
      pkg/api/pluginproxy/ds_proxy_test.go
  38. 4 1
      pkg/api/render.go
  39. 14 3
      pkg/api/search.go
  40. 92 0
      pkg/api/team.go
  41. 44 0
      pkg/api/team_members.go
  42. 71 0
      pkg/api/team_test.go
  43. 6 1
      pkg/api/user.go
  44. 1 1
      pkg/cmd/grafana-server/main.go
  45. 20 7
      pkg/components/renderer/renderer.go
  46. 1 1
      pkg/middleware/middleware.go
  47. 3 2
      pkg/middleware/render_auth.go
  48. 95 0
      pkg/models/dashboard_acl.go
  49. 21 0
      pkg/models/dashboard_acl_test.go
  50. 18 1
      pkg/models/dashboards.go
  51. 23 0
      pkg/models/dashboards_test.go
  52. 8 7
      pkg/models/org_user.go
  53. 75 0
      pkg/models/team.go
  54. 55 0
      pkg/models/team_member.go
  55. 21 0
      pkg/models/user.go
  56. 1 0
      pkg/plugins/dashboard_importer.go
  57. 26 20
      pkg/plugins/dashboard_importer_test.go
  58. 5 2
      pkg/plugins/dashboards.go
  59. 2 2
      pkg/plugins/dashboards_updater.go
  60. 0 8
      pkg/services/alerting/eval_context.go
  61. 0 16
      pkg/services/alerting/eval_context_test.go
  62. 5 0
      pkg/services/alerting/eval_handler.go
  63. 37 0
      pkg/services/alerting/eval_handler_test.go
  64. 1 1
      pkg/services/alerting/interfaces.go
  65. 11 24
      pkg/services/alerting/notifier.go
  66. 0 89
      pkg/services/alerting/notifier_test.go
  67. 96 0
      pkg/services/alerting/notifiers/alertmanager.go
  68. 47 0
      pkg/services/alerting/notifiers/alertmanager_test.go
  69. 9 2
      pkg/services/alerting/notifiers/base.go
  70. 32 0
      pkg/services/alerting/notifiers/base_test.go
  71. 4 0
      pkg/services/alerting/notifiers/dingding.go
  72. 3 5
      pkg/services/alerting/notifiers/dingding_test.go
  73. 4 0
      pkg/services/alerting/notifiers/email.go
  74. 4 0
      pkg/services/alerting/notifiers/hipchat.go
  75. 4 0
      pkg/services/alerting/notifiers/kafka.go
  76. 4 0
      pkg/services/alerting/notifiers/line.go
  77. 4 0
      pkg/services/alerting/notifiers/opsgenie.go
  78. 4 0
      pkg/services/alerting/notifiers/pagerduty.go
  79. 4 0
      pkg/services/alerting/notifiers/pushover.go
  80. 4 0
      pkg/services/alerting/notifiers/sensu.go
  81. 4 0
      pkg/services/alerting/notifiers/slack.go
  82. 0 1
      pkg/services/alerting/notifiers/slack_test.go
  83. 4 0
      pkg/services/alerting/notifiers/teams.go
  84. 4 0
      pkg/services/alerting/notifiers/telegram.go
  85. 4 0
      pkg/services/alerting/notifiers/threema.go
  86. 4 0
      pkg/services/alerting/notifiers/victorops.go
  87. 4 0
      pkg/services/alerting/notifiers/webhook.go
  88. 3 3
      pkg/services/alerting/notifiers/webhook_test.go
  89. 2 4
      pkg/services/alerting/result_handler.go
  90. 8 8
      pkg/services/dashboards/dashboards.go
  91. 130 0
      pkg/services/guardian/guardian.go
  92. 3 0
      pkg/services/provisioning/dashboards/file_reader.go
  93. 0 3
      pkg/services/provisioning/dashboards/types.go
  94. 7 35
      pkg/services/search/handlers.go
  95. 19 22
      pkg/services/search/handlers_test.go
  96. 40 17
      pkg/services/search/models.go
  97. 2 2
      pkg/services/sqlstore/alert_test.go
  98. 96 54
      pkg/services/sqlstore/dashboard.go
  99. 184 0
      pkg/services/sqlstore/dashboard_acl.go
  100. 236 0
      pkg/services/sqlstore/dashboard_acl_test.go

+ 3 - 0
.gitignore

@@ -51,6 +51,9 @@ debug.test
 /packaging/**/*.rpm
 /packaging/**/*.deb
 
+# Ignore OSX indexing
+.DS_Store
+
 /vendor/**/*.py
 /vendor/**/*.xml
 /vendor/**/*.yml

+ 24 - 14
CHANGELOG.md

@@ -1,20 +1,27 @@
-# 5.0.0 (unreleased)
+# 5.0.0 (unreleased / master branch)
 
-### WIP (in develop branch currently as its unstable or unfinished)
-- Dashboard folders
-- User groups
-- Dashboard permissions (on folder & dashboard level), permissions can be assigned to groups or individual users
-- UX changes to nav & side menu
-- New dashboard grid layout system
+### New Features
+- **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611)
+- **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).
+- **Templating**: Vertical repeat direction for panel repeats.
+- **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)
+
+## 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.
 
-# 4.7.0 (unreleased)
+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.
+
+# 4.7.0 (unreleased / v4.7.x branch)
 
 ## 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`. 
-From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. 
+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.
 
 ## New Features
 * **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)
@@ -25,7 +32,8 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
 * **Graphite**: Query editor updated to support new query by tag features [#9230](https://github.com/grafana/grafana/issues/9230)
 * **Dashboard history**: New config file option versions_to_keep sets how many versions per dashboard to store, [#9671](https://github.com/grafana/grafana/issues/9671)
 * **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)
+* **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal)
 ## Minor
 * **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)
@@ -33,7 +41,7 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
 * **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871)
 * **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798)
 * **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler)
-* **Github**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx 
+* **Github**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx
 [@adiletmaratov](https://github.com/adiletmaratov)
 * **Backend**: Fixed bug where Grafana exited before all sub routines where finished [#10131](https://github.com/grafana/grafana/issues/10131)
 
@@ -46,11 +54,13 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
 * **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)
 
-# 4.6.3 (unreleased)
+# 4.6.3 (2017-12-14)
 
 ## Fixes
 * **Gzip**: Fixes bug gravatar images when gzip was enabled [#5952](https://github.com/grafana/grafana/issues/5952)
 * **Alert list**: Now shows alert state changes even after adding manual annotations on dashboard [#9951](https://github.com/grafana/grafana/issues/9951)
+* **Alerting**: Fixes bug where rules evaluated as firing when all conditions was false and using OR operator. [#9318](https://github.com/grafana/grafana/issues/9318)
+* **Cloudwatch**: CloudWatch no longer display metrics' default alias [#10151](https://github.com/grafana/grafana/issues/10151), thx [@mtanda](https://github.com/mtanda)
 
 # 4.6.2 (2017-11-16)
 

+ 2 - 2
README.md

@@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra
 Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
 
 ## Run from master
-If you want to build a package yourself, or contribute. Here is a guide for how to do that. You can always find
+If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find
 the latest master builds [here](https://grafana.com/grafana/download)
 
 ### Dependencies
@@ -97,7 +97,7 @@ Writing & watching frontend tests (we have two test runners)
 
 ## Contribute
 
-If you have any idea for an improvement or found a bug do not hesitate to open an issue.
+If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
 And if you have time clone this repo and submit a pull request and help me make Grafana
 the kickass metrics & devops dashboard we all dream about!
 

+ 1 - 1
ROADMAP.md

@@ -6,7 +6,7 @@ But it will give you an idea of our current vision and plan.
 ### Short term (1-4 months)
 
  - Release Grafana v5
-  - User groups
+  - Teams
   - Dashboard folders
   - Dashboard & folder permissions (assigned to users or groups)
   - New Dashboard layout engine

+ 3 - 0
conf/defaults.ini

@@ -221,6 +221,9 @@ external_manage_link_url =
 external_manage_link_name =
 external_manage_info =
 
+# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
+viewers_can_edit = false
+
 [auth]
 # Set to true to disable (hide) the login form, useful if you use OAuth
 disable_login_form = false

+ 3 - 0
conf/sample.ini

@@ -205,6 +205,9 @@ log_queries =
 ;external_manage_link_name =
 ;external_manage_info =
 
+# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
+;viewers_can_edit = false
+
 [auth]
 # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false
 ;disable_login_form = false

+ 1 - 1
docker/blocks/graphite/docker-compose.yaml

@@ -1,4 +1,4 @@
-  graphite:
+  graphite09:
     build: blocks/graphite
     ports:
       - "8080:80"

+ 18 - 17
docs/sources/alerting/notifications.md

@@ -126,25 +126,26 @@ There are couple of configurations options which need to be set in Grafana UI un
 
 Once these two properties are set, you can send the alerts to Kafka for further processing or throttling them.
 
-### Other Supported Notification Channels
+### All supported notifier
+
+Name | Type |Support images 
+-----|------------ | ------  
+Slack | `slack` | yes
+Pagerduty | `pagerduty` | yes
+Email | `email` | yes
+Webhook | `webhook` | link
+Kafka | `kafka` | no
+Hipchat | `hipchat` | yes
+VictorOps | `victorops` | yes
+Sensu | `sensu` | yes
+OpsGenie | `opsgenie` | yes
+Threema | `threema` | yes
+Pushover | `pushover` | no
+Telegram | `telegram` | no
+Line | `line` | no
+Prometheus Alertmanager | `prometheus-alertmanager` | no
 
-Grafana also supports the following Notification Channels:
 
-- HipChat
-
-- VictorOps
-
-- Sensu
-
-- OpsGenie
-
-- Threema
-
-- Pushover
-
-- Telegram
-
-- LINE
 
 # Enable images in notifications {#external-image-store}
 

+ 2 - 0
docs/sources/http_api/alerting.md

@@ -196,6 +196,8 @@ Content-Type: application/json
 
 ## Create alert notification
 
+You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. 
+
 `POST /api/alert-notifications`
 
 **Example Request**:

+ 1 - 1
docs/sources/http_api/auth.md

@@ -100,7 +100,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 JSON Body schema:
 
 - **name** – The key name
-- **role** – Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor`, `Read Only Editor` or `Admin`.
+- **role** – Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor` or `Admin`.
 
 **Example Response**:
 

+ 11 - 7
docs/sources/installation/configuration.md

@@ -205,7 +205,7 @@ The database user (not applicable for `sqlite3`).
 
 ### password
 
-The database user's password (not applicable for `sqlite3`). If the password contains `#` or `;` you have to wrap it with trippel quotes. Ex `"""#password;"""`
+The database user's password (not applicable for `sqlite3`). If the password contains `#` or `;` you have to wrap it with triple quotes. Ex `"""#password;"""`
 
 ### ssl_mode
 
@@ -214,19 +214,19 @@ For MySQL, use either `true`, `false`, or `skip-verify`.
 
 ### ca_cert_path
 
-(MySQL only) The path to the CA certificate to use. On many linux systems, certs can be found in `/etc/ssl/certs`.
+The path to the CA certificate to use. On many linux systems, certs can be found in `/etc/ssl/certs`.
 
 ### client_key_path
 
-(MySQL only) The path to the client key. Only if server requires client authentication.
+The path to the client key. Only if server requires client authentication.
 
 ### client_cert_path
 
-(MySQL only) The path to the client cert. Only if server requires client authentication.
+The path to the client cert. Only if server requires client authentication.
 
 ### server_cert_name
 
-(MySQL only) The common name field of the certificate used by the `mysql` server. Not necessary if `ssl_mode` is set to `skip-verify`.
+The common name field of the certificate used by the `mysql` or `postgres` server. Not necessary if `ssl_mode` is set to `skip-verify`.
 
 ### max_idle_conn
 The maximum number of connections in the idle connection pool.
@@ -292,10 +292,14 @@ organization to be created for that new user.
 
 The role new users will be assigned for the main organization (if the
 above setting is set to true).  Defaults to `Viewer`, other valid
-options are `Admin` and `Editor` and `Read Only Editor`. e.g. :
+options are `Admin` and `Editor`. e.g. :
 
-`auto_assign_org_role = Read Only Editor`
+`auto_assign_org_role = Viewer`
 
+### viewers can edit
+
+Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
+Defaults to `false`.
 
 <hr>
 

+ 3 - 16
docs/sources/installation/debian.md

@@ -15,9 +15,7 @@ weight = 1
 
 Description | Download
 ------------ | -------------
-Stable for Debian-based Linux | [grafana_4.6.2_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.2_amd64.deb)
-
-<!-- Beta for Debian-based Linux | [grafana_4.5.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.0-beta1_amd64.deb) -->
+Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb)
 
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 installation.
@@ -26,21 +24,10 @@ installation.
 
 
 ```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.2_amd64.deb
+wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb
 sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_4.6.2_amd64.deb
+sudo dpkg -i grafana_4.6.3_amd64.deb
 ```
-
-<!--
-## Install Latest Beta
-
-```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.2-beta1_amd64.deb
-sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_4.5.2-beta1_amd64.deb
-```
--->
-
 ## APT Repository
 
 Add the following line to your `/etc/apt/sources.list` file.

+ 5 - 7
docs/sources/installation/rpm.md

@@ -15,9 +15,7 @@ weight = 2
 
 Description | Download
 ------------ | -------------
-Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.2 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2-1.x86_64.rpm)
-
-<!-- Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [4.5.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.0-beta1.x86_64.rpm) -->
+Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm)
 
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 installation.
@@ -27,7 +25,7 @@ installation.
 You can install Grafana using Yum directly.
 
 ```bash
-$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2-1.x86_64.rpm
+$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm
 ```
 
 Or install manually using `rpm`.
@@ -35,15 +33,15 @@ Or install manually using `rpm`.
 #### On CentOS / Fedora / Redhat:
 
 ```bash
-$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2-1.x86_64.rpm
+$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm
 $ sudo yum install initscripts fontconfig
-$ sudo rpm -Uvh grafana-4.6.2-1.x86_64.rpm
+$ sudo rpm -Uvh grafana-4.6.3-1.x86_64.rpm
 ```
 
 #### On OpenSuse:
 
 ```bash
-$ sudo rpm -i --nodeps grafana-4.6.2-1.x86_64.rpm
+$ sudo rpm -i --nodeps grafana-4.6.3-1.x86_64.rpm
 ```
 
 ## Install via YUM Repository

+ 1 - 1
docs/sources/installation/windows.md

@@ -13,7 +13,7 @@ weight = 3
 
 Description | Download
 ------------ | -------------
-Latest stable package for Windows | [grafana.4.6.2.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.2.windows-x64.zip)
+Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip)
 
 Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
 installation.

+ 1 - 1
jest.config.js

@@ -1,6 +1,6 @@
 
 module.exports = {
-  verbose: true,
+  verbose: false,
   "globals": {
     "ts-jest": {
       "tsConfigFile": "tsconfig.json"

+ 14 - 10
package.json

@@ -4,7 +4,7 @@
     "company": "Grafana Labs"
   },
   "name": "grafana",
-  "version": "4.7.0-pre1",
+  "version": "5.0.0-pre1",
   "repository": {
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"
@@ -14,8 +14,8 @@
     "@types/enzyme": "^2.8.9",
     "@types/jest": "^21.1.4",
     "@types/node": "^8.0.31",
-    "@types/react": "^16.0.5",
-    "@types/react-dom": "^15.5.4",
+    "@types/react": "^16.0.25",
+    "@types/react-dom": "^16.0.3",
     "angular-mocks": "^1.6.6",
     "autoprefixer": "^6.4.0",
     "awesome-typescript-loader": "^3.2.3",
@@ -115,22 +115,26 @@
     "angular-sanitize": "^1.6.6",
     "babel-polyfill": "^6.26.0",
     "brace": "^0.10.0",
+    "classnames": "^2.2.5",
     "clipboard": "^1.7.1",
-    "eventemitter3": "^2.0.3",
+    "d3": "^4.11.0",
+    "d3-scale-chromatic": "^1.1.1",
+    "eventemitter3": "^2.0.2",
     "file-saver": "^1.3.3",
     "jquery": "^3.2.1",
     "lodash": "^4.17.4",
     "moment": "^2.18.1",
     "mousetrap": "^1.6.0",
-    "ngreact": "^0.4.1",
-    "react": "^16.0.0",
-    "react-dom": "^16.0.0",
+    "perfect-scrollbar": "^1.2.0",
+    "prop-types": "^15.6.0",
+    "react": "^16.1.1",
+    "react-dom": "^16.1.1",
+    "react-grid-layout": "^0.16.1",
+    "react-sizeme": "^2.3.6",
     "remarkable": "^1.7.1",
     "rxjs": "^5.4.3",
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop",
-    "tinycolor2": "^1.4.1",
-    "d3": "^4.11.0",
-    "d3-scale-chromatic": "^1.1.1"
+    "tinycolor2": "^1.4.1"
   }
 }

+ 1 - 1
packaging/publish/publish_both.sh

@@ -1,5 +1,5 @@
 #! /usr/bin/env bash
-version=4.6.2
+version=4.6.3
 
 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb
 

+ 30 - 7
pkg/api/api.go

@@ -40,9 +40,11 @@ func (hs *HttpServer) registerRoutes() {
 	r.Get("/datasources/", reqSignedIn, Index)
 	r.Get("/datasources/new", reqSignedIn, Index)
 	r.Get("/datasources/edit/*", reqSignedIn, Index)
-	r.Get("/org/users/", reqSignedIn, Index)
+	r.Get("/org/users/new", reqSignedIn, Index)
+	r.Get("/org/users/invite", reqSignedIn, Index)
 	r.Get("/org/apikeys/", reqSignedIn, Index)
 	r.Get("/dashboard/import/", reqSignedIn, Index)
+	r.Get("/configuration", reqGrafanaAdmin, Index)
 	r.Get("/admin", reqGrafanaAdmin, Index)
 	r.Get("/admin/settings", reqGrafanaAdmin, Index)
 	r.Get("/admin/users", reqGrafanaAdmin, Index)
@@ -62,6 +64,7 @@ func (hs *HttpServer) registerRoutes() {
 	r.Get("/dashboard-solo/snapshot/*", Index)
 	r.Get("/dashboard-solo/*", reqSignedIn, Index)
 	r.Get("/import/dashboard", reqSignedIn, Index)
+	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
 
 	r.Get("/playlists/", reqSignedIn, Index)
@@ -134,6 +137,18 @@ func (hs *HttpServer) registerRoutes() {
 			usersRoute.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg))
 		}, reqGrafanaAdmin)
 
+		// team (admin permission required)
+		apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
+			teamsRoute.Get("/:teamId", wrap(GetTeamById))
+			teamsRoute.Get("/search", wrap(SearchTeams))
+			teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
+			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
+			teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
+			teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
+			teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
+		}, reqOrgAdmin)
+
 		// org information available to all users.
 		apiRoute.Group("/org", func(orgRoute RouteRegister) {
 			orgRoute.Get("/", wrap(GetOrgCurrent))
@@ -224,12 +239,8 @@ func (hs *HttpServer) registerRoutes() {
 
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
-			dashboardRoute.Get("/db/:slug", GetDashboard)
-			dashboardRoute.Delete("/db/:slug", reqEditorRole, DeleteDashboard)
-
-			dashboardRoute.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions))
-			dashboardRoute.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion))
-			dashboardRoute.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
+			dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
+			dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard))
 
 			dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
 
@@ -237,6 +248,18 @@ func (hs *HttpServer) registerRoutes() {
 			dashboardRoute.Get("/home", wrap(GetHomeDashboard))
 			dashboardRoute.Get("/tags", GetDashboardTags)
 			dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
+
+			dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) {
+				dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
+				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
+				dashIdRoute.Post("/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
+
+				dashIdRoute.Group("/acl", func(aclRoute RouteRegister) {
+					aclRoute.Get("/", wrap(GetDashboardAclList))
+					aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl))
+					aclRoute.Delete("/:aclId", wrap(DeleteDashboardAcl))
+				})
+			})
 		})
 
 		// Dashboard snapshots

+ 8 - 4
pkg/api/avatar/avatar.go

@@ -25,6 +25,8 @@ import (
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/setting"
 	"gopkg.in/macaron.v1"
+
+	gocache "github.com/patrickmn/go-cache"
 )
 
 var gravatarSource string
@@ -92,7 +94,7 @@ func (this *Avatar) Update() (err error) {
 
 type CacheServer struct {
 	notFound *Avatar
-	cache    map[string]*Avatar
+	cache    *gocache.Cache
 }
 
 func (this *CacheServer) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) {
@@ -110,7 +112,9 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
 
 	var avatar *Avatar
 
-	if avatar, _ = this.cache[hash]; avatar == nil {
+	if obj, exist := this.cache.Get(hash); exist {
+		avatar = obj.(*Avatar)
+	} else {
 		avatar = New(hash)
 	}
 
@@ -124,7 +128,7 @@ func (this *CacheServer) Handler(ctx *macaron.Context) {
 	if avatar.notFound {
 		avatar = this.notFound
 	} else {
-		this.cache[hash] = avatar
+		this.cache.Add(hash, avatar, gocache.DefaultExpiration)
 	}
 
 	ctx.Resp.Header().Add("Content-Type", "image/jpeg")
@@ -146,7 +150,7 @@ func NewCacheServer() *CacheServer {
 
 	return &CacheServer{
 		notFound: newNotFound(),
-		cache:    make(map[string]*Avatar),
+		cache:    gocache.New(time.Hour, time.Hour*2),
 	}
 }
 

+ 127 - 72
pkg/api/dashboard.go

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"os"
 	"path"
-	"strings"
 
 	"github.com/grafana/grafana/pkg/services/dashboards"
 
@@ -18,6 +17,7 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
@@ -35,23 +35,34 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error)
 	return query.Result, nil
 }
 
-func GetDashboard(c *middleware.Context) {
-	slug := strings.ToLower(c.Params(":slug"))
-
-	query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId}
-	err := bus.Dispatch(&query)
+func dashboardGuardianResponse(err error) Response {
 	if err != nil {
-		c.JsonApiErr(404, "Dashboard not found", nil)
-		return
+		return ApiError(500, "Error while checking dashboard permissions", err)
+	} else {
+		return ApiError(403, "Access denied to this dashboard", nil)
+	}
+}
+
+func GetDashboard(c *middleware.Context) Response {
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	if rsp != nil {
+		return rsp
 	}
 
-	isStarred, err := isDashboardStarredByUser(c, query.Result.Id)
-	if err != nil {
-		c.JsonApiErr(500, "Error while checking if dashboard was starred by user", err)
-		return
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canView, err := guardian.CanView(); err != nil || !canView {
+		fmt.Printf("%v", err)
+		return dashboardGuardianResponse(err)
 	}
 
-	dash := query.Result
+	canEdit, _ := guardian.CanEdit()
+	canSave, _ := guardian.CanSave()
+	canAdmin, _ := guardian.CanAdmin()
+
+	isStarred, err := isDashboardStarredByUser(c, dash.Id)
+	if err != nil {
+		return ApiError(500, "Error while checking if dashboard was starred by user", err)
+	}
 
 	// Finding creator and last updater of the dashboard
 	updater, creator := "Anonymous", "Anonymous"
@@ -62,29 +73,44 @@ func GetDashboard(c *middleware.Context) {
 		creator = getUserLogin(dash.CreatedBy)
 	}
 
+	meta := dtos.DashboardMeta{
+		IsStarred:   isStarred,
+		Slug:        dash.Slug,
+		Type:        m.DashTypeDB,
+		CanStar:     c.IsSignedIn,
+		CanSave:     canSave,
+		CanEdit:     canEdit,
+		CanAdmin:    canAdmin,
+		Created:     dash.Created,
+		Updated:     dash.Updated,
+		UpdatedBy:   updater,
+		CreatedBy:   creator,
+		Version:     dash.Version,
+		HasAcl:      dash.HasAcl,
+		IsFolder:    dash.IsFolder,
+		FolderId:    dash.FolderId,
+		FolderTitle: "Root",
+	}
+
+	// lookup folder title
+	if dash.FolderId > 0 {
+		query := m.GetDashboardQuery{Id: dash.FolderId, OrgId: c.OrgId}
+		if err := bus.Dispatch(&query); err != nil {
+			return ApiError(500, "Dashboard folder could not be read", err)
+		}
+		meta.FolderTitle = query.Result.Title
+	}
+
 	// make sure db version is in sync with json model version
 	dash.Data.Set("version", dash.Version)
 
 	dto := dtos.DashboardFullWithMeta{
 		Dashboard: dash.Data,
-		Meta: dtos.DashboardMeta{
-			IsStarred: isStarred,
-			Slug:      slug,
-			Type:      m.DashTypeDB,
-			CanStar:   c.IsSignedIn,
-			CanSave:   c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR,
-			CanEdit:   canEditDashboard(c.OrgRole),
-			Created:   dash.Created,
-			Updated:   dash.Updated,
-			UpdatedBy: updater,
-			CreatedBy: creator,
-			Version:   dash.Version,
-		},
+		Meta:      meta,
 	}
 
-	// TODO(ben): copy this performance metrics logic for the new API endpoints added
 	c.TimeRequest(metrics.M_Api_Dashboard_Get)
-	c.JSON(200, dto)
+	return Json(200, dto)
 }
 
 func getUserLogin(userId int64) string {
@@ -98,24 +124,32 @@ func getUserLogin(userId int64) string {
 	}
 }
 
-func DeleteDashboard(c *middleware.Context) {
-	slug := c.Params(":slug")
-
-	query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId}
+func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) {
+	query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
 	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(404, "Dashboard not found", nil)
-		return
+		return nil, ApiError(404, "Dashboard not found", err)
+	}
+	return query.Result, nil
+}
+
+func DeleteDashboard(c *middleware.Context) Response {
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	if rsp != nil {
+		return rsp
 	}
 
-	cmd := m.DeleteDashboardCommand{Slug: slug, OrgId: c.OrgId}
-	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to delete dashboard", err)
-		return
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
 	}
 
-	var resp = map[string]interface{}{"title": query.Result.Title}
+	cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id}
+	if err := bus.Dispatch(&cmd); err != nil {
+		return ApiError(500, "Failed to delete dashboard", err)
+	}
 
-	c.JSON(200, resp)
+	var resp = map[string]interface{}{"title": dash.Title}
+	return Json(200, resp)
 }
 
 func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
@@ -124,6 +158,20 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 
 	dash := cmd.GetDashboardModel()
 
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
+	if dash.IsFolder && dash.FolderId > 0 {
+		return ApiError(400, m.ErrDashboardFolderCannotHaveParent.Error(), nil)
+	}
+
+	// Check if Title is empty
+	if dash.Title == "" {
+		return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
+	}
+
 	if dash.Id == 0 {
 		limitReached, err := middleware.QuotaReached(c, "dashboard")
 		if err != nil {
@@ -139,6 +187,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		Message:   cmd.Message,
 		OrgId:     c.OrgId,
 		UserId:    c.UserId,
+		Overwrite: cmd.Overwrite,
 	}
 
 	dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem)
@@ -177,11 +226,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	}
 
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
-	return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version})
-}
-
-func canEditDashboard(role m.RoleType) bool {
-	return role == m.ROLE_ADMIN || role == m.ROLE_EDITOR || role == m.ROLE_READ_ONLY_EDITOR
+	return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id})
 }
 
 func GetHomeDashboard(c *middleware.Context) Response {
@@ -209,7 +254,9 @@ func GetHomeDashboard(c *middleware.Context) Response {
 
 	dash := dtos.DashboardFullWithMeta{}
 	dash.Meta.IsHome = true
-	dash.Meta.CanEdit = canEditDashboard(c.OrgRole)
+	dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR)
+	dash.Meta.FolderTitle = "Root"
+
 	jsonParser := json.NewDecoder(file)
 	if err := jsonParser.Decode(&dash.Dashboard); err != nil {
 		return ApiError(500, "Failed to load home dashboard", err)
@@ -223,39 +270,41 @@ func GetHomeDashboard(c *middleware.Context) Response {
 }
 
 func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
-	rows := dash.Get("rows").MustArray()
-	row := simplejson.NewFromAny(rows[0])
+	panels := dash.Get("panels").MustArray()
 
 	newpanel := simplejson.NewFromAny(map[string]interface{}{
 		"type": "gettingstarted",
 		"id":   123123,
-		"span": 12,
+		"gridPos": map[string]interface{}{
+			"x": 0,
+			"y": 3,
+			"w": 24,
+			"h": 4,
+		},
 	})
 
-	panels := row.Get("panels").MustArray()
 	panels = append(panels, newpanel)
-	row.Set("panels", panels)
+	dash.Set("panels", panels)
 }
 
 // GetDashboardVersions returns all dashboard versions as JSON
 func GetDashboardVersions(c *middleware.Context) Response {
-	dashboardId := c.ParamsInt64(":dashboardId")
-	limit := c.QueryInt("limit")
-	start := c.QueryInt("start")
+	dashId := c.ParamsInt64(":dashboardId")
 
-	if limit == 0 {
-		limit = 1000
+	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
 	}
 
 	query := m.GetDashboardVersionsQuery{
 		OrgId:       c.OrgId,
-		DashboardId: dashboardId,
-		Limit:       limit,
-		Start:       start,
+		DashboardId: dashId,
+		Limit:       c.QueryInt("limit"),
+		Start:       c.QueryInt("start"),
 	}
 
 	if err := bus.Dispatch(&query); err != nil {
-		return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashboardId), err)
+		return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashId), err)
 	}
 
 	for _, version := range query.Result {
@@ -279,17 +328,21 @@ func GetDashboardVersions(c *middleware.Context) Response {
 
 // GetDashboardVersion returns the dashboard version with the given ID.
 func GetDashboardVersion(c *middleware.Context) Response {
-	dashboardId := c.ParamsInt64(":dashboardId")
-	version := c.ParamsInt(":id")
+	dashId := c.ParamsInt64(":dashboardId")
+
+	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
 
 	query := m.GetDashboardVersionQuery{
 		OrgId:       c.OrgId,
-		DashboardId: dashboardId,
-		Version:     version,
+		DashboardId: dashId,
+		Version:     c.ParamsInt(":id"),
 	}
 
 	if err := bus.Dispatch(&query); err != nil {
-		return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", version, dashboardId), err)
+		return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", query.Version, dashId), err)
 	}
 
 	creator := "Anonymous"
@@ -340,19 +393,21 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff
 
 // RestoreDashboardVersion restores a dashboard to the given version.
 func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
-	dashboardId := c.ParamsInt64(":dashboardId")
+	dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"))
+	if rsp != nil {
+		return rsp
+	}
 
-	dashQuery := m.GetDashboardQuery{Id: dashboardId, OrgId: c.OrgId}
-	if err := bus.Dispatch(&dashQuery); err != nil {
-		return ApiError(404, "Dashboard not found", nil)
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
 	}
 
-	versionQuery := m.GetDashboardVersionQuery{DashboardId: dashboardId, Version: apiCmd.Version, OrgId: c.OrgId}
+	versionQuery := m.GetDashboardVersionQuery{DashboardId: dash.Id, Version: apiCmd.Version, OrgId: c.OrgId}
 	if err := bus.Dispatch(&versionQuery); err != nil {
 		return ApiError(404, "Dashboard version not found", nil)
 	}
 
-	dashboard := dashQuery.Result
 	version := versionQuery.Result
 
 	saveCmd := m.SaveDashboardCommand{}
@@ -360,7 +415,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard
 	saveCmd.OrgId = c.OrgId
 	saveCmd.UserId = c.UserId
 	saveCmd.Dashboard = version.Data
-	saveCmd.Dashboard.Set("version", dashboard.Version)
+	saveCmd.Dashboard.Set("version", dash.Version)
 	saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
 
 	return PostDashboard(c, saveCmd)

+ 79 - 0
pkg/api/dashboard_acl.go

@@ -0,0 +1,79 @@
+package api
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
+)
+
+func GetDashboardAclList(c *middleware.Context) Response {
+	dashId := c.ParamsInt64(":dashboardId")
+
+	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
+
+	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
+		return dashboardGuardianResponse(err)
+	}
+
+	acl, err := guardian.GetAcl()
+	if err != nil {
+		return ApiError(500, "Failed to get dashboard acl", err)
+	}
+
+	return Json(200, acl)
+}
+
+func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
+	dashId := c.ParamsInt64(":dashboardId")
+
+	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
+	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
+		return dashboardGuardianResponse(err)
+	}
+
+	cmd := m.UpdateDashboardAclCommand{}
+	cmd.DashboardId = dashId
+
+	for _, item := range apiCmd.Items {
+		cmd.Items = append(cmd.Items, &m.DashboardAcl{
+			OrgId:       c.OrgId,
+			DashboardId: dashId,
+			UserId:      item.UserId,
+			TeamId:      item.TeamId,
+			Role:        item.Role,
+			Permission:  item.Permission,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+		})
+	}
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty {
+			return ApiError(409, err.Error(), err)
+		}
+		return ApiError(500, "Failed to create permission", err)
+	}
+
+	return ApiSuccess("Dashboard acl updated")
+}
+
+func DeleteDashboardAcl(c *middleware.Context) Response {
+	dashId := c.ParamsInt64(":dashboardId")
+	aclId := c.ParamsInt64(":aclId")
+
+	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
+	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
+		return dashboardGuardianResponse(err)
+	}
+
+	cmd := m.RemoveDashboardAclCommand{OrgId: c.OrgId, AclId: aclId}
+	if err := bus.Dispatch(&cmd); err != nil {
+		return ApiError(500, "Failed to delete permission for user", err)
+	}
+
+	return Json(200, "")
+}

+ 174 - 0
pkg/api/dashboard_acl_test.go

@@ -0,0 +1,174 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDashboardAclApiEndpoint(t *testing.T) {
+	Convey("Given a dashboard acl", t, func() {
+		mockResult := []*m.DashboardAclInfoDTO{
+			{Id: 1, OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
+			{Id: 2, OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
+			{Id: 3, OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
+			{Id: 4, OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
+			{Id: 5, OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
+		}
+		dtoRes := transformDashboardAclsToDTOs(mockResult)
+
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = dtoRes
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = mockResult
+			return nil
+		})
+
+		teamResp := []*m.Team{}
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = teamResp
+			return nil
+		})
+
+		Convey("When user is org admin", func() {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
+				Convey("Should be able to access ACL", func() {
+					sc.handlerFunc = GetDashboardAclList
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+
+					respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+					So(err, ShouldBeNil)
+					So(len(respJSON.MustArray()), ShouldEqual, 5)
+					So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
+					So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
+				})
+			})
+		})
+
+		Convey("When user is editor and has admin permission in the ACL", func() {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+
+				Convey("Should be able to access ACL", func() {
+					sc.handlerFunc = GetDashboardAclList
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
+
+				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
+					return nil
+				})
+
+				Convey("Should be able to delete permission", func() {
+					sc.handlerFunc = DeleteDashboardAcl
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+
+			Convey("When user is a member of a team in the ACL with admin permission", func() {
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"})
+
+					bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
+						return nil
+					})
+
+					Convey("Should be able to delete permission", func() {
+						sc.handlerFunc = DeleteDashboardAcl
+						sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+
+						So(sc.resp.Code, ShouldEqual, 200)
+					})
+				})
+			})
+		})
+
+		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) {
+				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() {
+					sc.handlerFunc = GetDashboardAclList
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				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 {
+					return nil
+				})
+
+				Convey("Should be not be able to delete permission", func() {
+					sc.handlerFunc = DeleteDashboardAcl
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+
+		Convey("When user is editor and not in the ACL", func() {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
+
+				Convey("Should not be able to access ACL", func() {
+					sc.handlerFunc = GetDashboardAclList
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/user/1", "/api/dashboards/id/:dashboardsId/acl/user/:userId", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW})
+				bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error {
+					return nil
+				})
+
+				Convey("Should be not be able to delete permission", func() {
+					sc.handlerFunc = DeleteDashboardAcl
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+	})
+}
+
+func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardAclInfoDTO {
+	dtos := make([]*m.DashboardAclInfoDTO, 0)
+
+	for _, acl := range acls {
+		dto := &m.DashboardAclInfoDTO{
+			Id:          acl.Id,
+			OrgId:       acl.OrgId,
+			DashboardId: acl.DashboardId,
+			Permission:  acl.Permission,
+			UserId:      acl.UserId,
+			TeamId:      acl.TeamId,
+		}
+		dtos = append(dtos, dto)
+	}
+
+	return dtos
+}

+ 521 - 0
pkg/api/dashboard_test.go

@@ -0,0 +1,521 @@
+package api
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	macaron "gopkg.in/macaron.v1"
+
+	"github.com/go-macaron/session"
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/setting"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+type fakeDashboardRepo struct {
+	inserted     []*dashboards.SaveDashboardItem
+	getDashboard []*m.Dashboard
+}
+
+func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) (*m.Dashboard, error) {
+	repo.inserted = append(repo.inserted, json)
+	return json.Dashboard, nil
+}
+
+var fakeRepo *fakeDashboardRepo
+
+func TestDashboardApiEndpoint(t *testing.T) {
+	Convey("Given a dashboard with a parent folder which does not have an acl", t, func() {
+		fakeDash := m.NewDashboard("Child dash")
+		fakeDash.Id = 1
+		fakeDash.FolderId = 1
+		fakeDash.HasAcl = false
+
+		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+			query.Result = fakeDash
+			return nil
+		})
+
+		viewerRole := m.ROLE_VIEWER
+		editorRole := m.ROLE_EDITOR
+
+		aclMockResp := []*m.DashboardAclInfoDTO{
+			{Role: &viewerRole, Permission: m.PERMISSION_VIEW},
+			{Role: &editorRole, Permission: m.PERMISSION_EDIT},
+		}
+
+		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
+		})
+
+		cmd := m.SaveDashboardCommand{
+			Dashboard: simplejson.NewFromAny(map[string]interface{}{
+				"folderId": fakeDash.FolderId,
+				"title":    fakeDash.Title,
+				"id":       fakeDash.Id,
+			}),
+		}
+
+		Convey("When user is an Org Viewer", func() {
+			role := m.ROLE_VIEWER
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				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 DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+		})
+
+		Convey("When user is an Org Editor", func() {
+			role := m.ROLE_EDITOR
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				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 DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			Convey("When saving a dashboard folder in another folder", func() {
+				bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+					query.Result = fakeDash
+					query.Result.IsFolder = true
+					return nil
+				})
+				invalidCmd := m.SaveDashboardCommand{
+					FolderId: fakeDash.FolderId,
+					IsFolder: true,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"folderId": fakeDash.FolderId,
+						"title":    fakeDash.Title,
+					}),
+				}
+				Convey("Should return an error", func() {
+					postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, invalidCmd, func(sc *scenarioContext) {
+						CallPostDashboard(sc)
+						So(sc.resp.Code, ShouldEqual, 400)
+					})
+				})
+			})
+		})
+	})
+
+	Convey("Given a dashboard with a parent folder which has an acl", t, func() {
+		fakeDash := m.NewDashboard("Child dash")
+		fakeDash.Id = 1
+		fakeDash.FolderId = 1
+		fakeDash.HasAcl = true
+		setting.ViewersCanEdit = false
+
+		aclMockResp := []*m.DashboardAclInfoDTO{
+			{
+				DashboardId: 1,
+				Permission:  m.PERMISSION_EDIT,
+				UserId:      200,
+			},
+		}
+
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = aclMockResp
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+			query.Result = fakeDash
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = []*m.Team{}
+			return nil
+		})
+
+		cmd := m.SaveDashboardCommand{
+			FolderId: fakeDash.FolderId,
+			Dashboard: simplejson.NewFromAny(map[string]interface{}{
+				"id":       fakeDash.Id,
+				"folderId": fakeDash.FolderId,
+				"title":    fakeDash.Title,
+			}),
+		}
+
+		Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
+			role := m.ROLE_VIEWER
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				sc.handlerFunc = GetDashboard
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should be denied access", func() {
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+		})
+
+		Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
+			role := m.ROLE_EDITOR
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				sc.handlerFunc = GetDashboard
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should be denied access", func() {
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+		})
+
+		Convey("When user is an Org Viewer but has an edit permission", func() {
+			role := m.ROLE_VIEWER
+
+			mockResult := []*m.DashboardAclInfoDTO{
+				{Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT},
+			}
+
+			bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+				query.Result = mockResult
+				return nil
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				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 DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+		})
+
+		Convey("When user is an Org Viewer and viewers can edit", func() {
+			role := m.ROLE_VIEWER
+			setting.ViewersCanEdit = true
+
+			mockResult := []*m.DashboardAclInfoDTO{
+				{Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW},
+			}
+
+			bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+				query.Result = mockResult
+				return nil
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				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 DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+		})
+
+		Convey("When user is an Org Viewer but has an admin permission", func() {
+			role := m.ROLE_VIEWER
+
+			mockResult := []*m.DashboardAclInfoDTO{
+				{Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN},
+			}
+
+			bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+				query.Result = mockResult
+				return nil
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				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 DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+		})
+
+		Convey("When user is an Org Editor but has a view permission", func() {
+			role := m.ROLE_EDITOR
+
+			mockResult := []*m.DashboardAclInfoDTO{
+				{Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW},
+			}
+
+			bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+				query.Result = mockResult
+				return nil
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should not be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeFalse)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+				CallDeleteDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
+				CallGetDashboardVersion(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) {
+				CallGetDashboardVersions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
+				CallPostDashboard(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+		})
+	})
+}
+
+func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
+	sc.handlerFunc = GetDashboard
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+	So(sc.resp.Code, ShouldEqual, 200)
+
+	dash := dtos.DashboardFullWithMeta{}
+	err := json.NewDecoder(sc.resp.Body).Decode(&dash)
+	So(err, ShouldBeNil)
+
+	return dash
+}
+
+func CallGetDashboardVersion(sc *scenarioContext) {
+	bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
+		query.Result = &m.DashboardVersion{}
+		return nil
+	})
+
+	sc.handlerFunc = GetDashboardVersion
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func CallGetDashboardVersions(sc *scenarioContext) {
+	bus.AddHandler("test", func(query *m.GetDashboardVersionsQuery) error {
+		query.Result = []*m.DashboardVersionDTO{}
+		return nil
+	})
+
+	sc.handlerFunc = GetDashboardVersions
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func CallDeleteDashboard(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error {
+		return nil
+	})
+
+	sc.handlerFunc = DeleteDashboard
+	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+}
+
+func CallPostDashboard(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
+		return nil
+	})
+
+	bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error {
+		cmd.Result = &m.Dashboard{Id: 2, Slug: "Dash", Version: 2}
+		return nil
+	})
+
+	bus.AddHandler("test", func(cmd *alerting.UpdateDashboardAlertsCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, 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
+
+			return PostDashboard(c, cmd)
+		})
+
+		fakeRepo = &fakeDashboardRepo{}
+		dashboards.SetRepository(fakeRepo)
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 11 - 2
pkg/api/datasources_test.go

@@ -56,6 +56,10 @@ 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()
 
@@ -77,7 +81,7 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID
-			sc.context.OrgRole = models.ROLE_EDITOR
+			sc.context.OrgRole = role
 			if sc.handlerFunc != nil {
 				return sc.handlerFunc(sc.context)
 			}
@@ -85,7 +89,12 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
 			return nil
 		})
 
-		sc.m.Get(url, sc.defaultHandler)
+		switch method {
+		case "GET":
+			sc.m.Get(routePattern, sc.defaultHandler)
+		case "DELETE":
+			sc.m.Delete(routePattern, sc.defaultHandler)
+		}
 
 		fn(sc)
 	})

+ 16 - 0
pkg/api/dtos/acl.go

@@ -0,0 +1,16 @@
+package dtos
+
+import (
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type UpdateDashboardAclCommand struct {
+	Items []DashboardAclUpdateItem `json:"items"`
+}
+
+type DashboardAclUpdateItem struct {
+	UserId     int64            `json:"userId"`
+	TeamId     int64            `json:"teamId"`
+	Role       *m.RoleType      `json:"role,omitempty"`
+	Permission m.PermissionType `json:"permission"`
+}

+ 19 - 14
pkg/api/dtos/dashboard.go

@@ -7,20 +7,25 @@ import (
 )
 
 type DashboardMeta struct {
-	IsStarred  bool      `json:"isStarred,omitempty"`
-	IsHome     bool      `json:"isHome,omitempty"`
-	IsSnapshot bool      `json:"isSnapshot,omitempty"`
-	Type       string    `json:"type,omitempty"`
-	CanSave    bool      `json:"canSave"`
-	CanEdit    bool      `json:"canEdit"`
-	CanStar    bool      `json:"canStar"`
-	Slug       string    `json:"slug"`
-	Expires    time.Time `json:"expires"`
-	Created    time.Time `json:"created"`
-	Updated    time.Time `json:"updated"`
-	UpdatedBy  string    `json:"updatedBy"`
-	CreatedBy  string    `json:"createdBy"`
-	Version    int       `json:"version"`
+	IsStarred   bool      `json:"isStarred,omitempty"`
+	IsHome      bool      `json:"isHome,omitempty"`
+	IsSnapshot  bool      `json:"isSnapshot,omitempty"`
+	Type        string    `json:"type,omitempty"`
+	CanSave     bool      `json:"canSave"`
+	CanEdit     bool      `json:"canEdit"`
+	CanAdmin    bool      `json:"canAdmin"`
+	CanStar     bool      `json:"canStar"`
+	Slug        string    `json:"slug"`
+	Expires     time.Time `json:"expires"`
+	Created     time.Time `json:"created"`
+	Updated     time.Time `json:"updated"`
+	UpdatedBy   string    `json:"updatedBy"`
+	CreatedBy   string    `json:"createdBy"`
+	Version     int       `json:"version"`
+	HasAcl      bool      `json:"hasAcl"`
+	IsFolder    bool      `json:"isFolder"`
+	FolderId    int64     `json:"folderId"`
+	FolderTitle string    `json:"folderTitle"`
 }
 
 type DashboardFullWithMeta struct {

+ 14 - 7
pkg/api/dtos/index.go

@@ -7,9 +7,10 @@ type IndexViewData struct {
 	AppSubUrl               string
 	GoogleAnalyticsId       string
 	GoogleTagManagerId      string
-	MainNavLinks            []*NavLink
+	NavTree                 []*NavLink
 	BuildVersion            string
 	BuildCommit             string
+	Theme                   string
 	NewGrafanaVersionExists bool
 	NewGrafanaVersion       string
 }
@@ -20,10 +21,16 @@ type PluginCss struct {
 }
 
 type NavLink struct {
-	Text     string     `json:"text,omitempty"`
-	Icon     string     `json:"icon,omitempty"`
-	Img      string     `json:"img,omitempty"`
-	Url      string     `json:"url,omitempty"`
-	Divider  bool       `json:"divider,omitempty"`
-	Children []*NavLink `json:"children,omitempty"`
+	Id           string     `json:"id,omitempty"`
+	Text         string     `json:"text,omitempty"`
+	Description  string     `json:"description,omitempty"`
+	SubTitle     string     `json:"subTitle,omitempty"`
+	Icon         string     `json:"icon,omitempty"`
+	Img          string     `json:"img,omitempty"`
+	Url          string     `json:"url,omitempty"`
+	Target       string     `json:"target,omitempty"`
+	Divider      bool       `json:"divider,omitempty"`
+	HideFromMenu bool       `json:"hideFromMenu,omitempty"`
+	HideFromTabs bool       `json:"hideFromTabs,omitempty"`
+	Children     []*NavLink `json:"children,omitempty"`
 }

+ 1 - 1
pkg/api/dtos/invite.go

@@ -6,7 +6,7 @@ type AddInviteForm struct {
 	LoginOrEmail string     `json:"loginOrEmail" binding:"Required"`
 	Name         string     `json:"name"`
 	Role         m.RoleType `json:"role" binding:"Required"`
-	SkipEmails   bool       `json:"skipEmails"`
+	SendEmail    bool       `json:"sendEmail"`
 }
 
 type InviteInfo struct {

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

@@ -27,6 +27,7 @@ type CurrentUser struct {
 	Email          string       `json:"email"`
 	Name           string       `json:"name"`
 	LightTheme     bool         `json:"lightTheme"`
+	OrgCount       int          `json:"orgCount"`
 	OrgId          int64        `json:"orgId"`
 	OrgName        string       `json:"orgName"`
 	OrgRole        m.RoleType   `json:"orgRole"`

+ 0 - 1
pkg/api/frontendsettings.go

@@ -143,7 +143,6 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
 		"alertingEnabled":         setting.AlertingEnabled,
 		"googleAnalyticsId":       setting.GoogleAnalyticsId,
 		"disableLoginForm":        setting.DisableLoginForm,
-		"disableSignoutMenu":      setting.DisableSignoutMenu,
 		"externalUserMngInfo":     setting.ExternalUserMngInfo,
 		"externalUserMngLinkUrl":  setting.ExternalUserMngLinkUrl,
 		"externalUserMngLinkName": setting.ExternalUserMngLinkName,

+ 145 - 40
pkg/api/index.go

@@ -50,6 +50,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 			Login:          c.Login,
 			Email:          c.Email,
 			Name:           c.Name,
+			OrgCount:       c.OrgCount,
 			OrgId:          c.OrgId,
 			OrgName:        c.OrgName,
 			OrgRole:        c.OrgRole,
@@ -61,6 +62,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 			HelpFlags1:     c.HelpFlags1,
 		},
 		Settings:                settings,
+		Theme:                   prefs.Theme,
 		AppUrl:                  appUrl,
 		AppSubUrl:               appSubUrl,
 		GoogleAnalyticsId:       setting.GoogleAnalyticsId,
@@ -82,55 +84,80 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	themeUrlParam := c.Query("theme")
 	if themeUrlParam == "light" {
 		data.User.LightTheme = true
+		data.Theme = "light"
 	}
 
-	dashboardChildNavs := []*dtos.NavLink{
-		{Text: "Home", Url: setting.AppSubUrl + "/"},
-		{Text: "Playlists", Url: setting.AppSubUrl + "/playlists"},
-		{Text: "Snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots"},
+	if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
+		data.NavTree = append(data.NavTree, &dtos.NavLink{
+			Text: "Create",
+			Id:   "create",
+			Icon: "fa fa-fw fa-plus",
+			Url:  setting.AppSubUrl + "dashboard/new",
+			Children: []*dtos.NavLink{
+				{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
+				{Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"},
+				{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"},
+			},
+		})
 	}
 
-	if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
-		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Divider: true})
-		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "New", Icon: "fa fa-plus", Url: setting.AppSubUrl + "/dashboard/new"})
-		dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{Text: "Import", Icon: "fa fa-download", Url: setting.AppSubUrl + "/dashboard/new/?editview=import"})
+	dashboardChildNavs := []*dtos.NavLink{
+		{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true},
+		{Divider: true, HideFromTabs: true},
+		{Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "gicon gicon-manage"},
+		{Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "gicon gicon-playlists"},
+		{Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "gicon gicon-snapshots"},
 	}
 
-	data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
+	data.NavTree = append(data.NavTree, &dtos.NavLink{
 		Text:     "Dashboards",
-		Icon:     "icon-gf icon-gf-dashboard",
+		Id:       "dashboards",
+		SubTitle: "Manage dashboards & folders",
+		Icon:     "gicon gicon-dashboard",
 		Url:      setting.AppSubUrl + "/",
 		Children: dashboardChildNavs,
 	})
 
+	if c.IsSignedIn {
+		profileNode := &dtos.NavLink{
+			Text:         c.SignedInUser.NameOrFallback(),
+			SubTitle:     c.SignedInUser.Login,
+			Id:           "profile",
+			Img:          data.User.GravatarUrl,
+			Url:          setting.AppSubUrl + "/profile",
+			HideFromMenu: true,
+			Children: []*dtos.NavLink{
+				{Text: "Preferences", Id: "profile-settings", Url: setting.AppSubUrl + "/profile", Icon: "gicon gicon-preferences"},
+				{Text: "Change Password", Id: "change-password", Url: setting.AppSubUrl + "/profile/password", Icon: "fa fa-fw fa-lock", HideFromMenu: true},
+			},
+		}
+
+		if !setting.DisableSignoutMenu {
+			// add sign out first
+			profileNode.Children = append(profileNode.Children, &dtos.NavLink{
+				Text: "Sign out", Id: "sign-out", Url: setting.AppSubUrl + "/logout", Icon: "fa fa-fw fa-sign-out", Target: "_self",
+			})
+		}
+
+		data.NavTree = append(data.NavTree, profileNode)
+	}
+
 	if setting.AlertingEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
 		alertChildNavs := []*dtos.NavLink{
-			{Text: "Alert List", Url: setting.AppSubUrl + "/alerting/list"},
-			{Text: "Notification channels", Url: setting.AppSubUrl + "/alerting/notifications"},
+			{Text: "Alert Rules", Id: "alert-list", Url: setting.AppSubUrl + "/alerting/list", Icon: "gicon gicon-alert-rules"},
+			{Text: "Notification channels", Id: "channels", Url: setting.AppSubUrl + "/alerting/notifications", Icon: "gicon gicon-alert-notification-channel"},
 		}
 
-		data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
+		data.NavTree = append(data.NavTree, &dtos.NavLink{
 			Text:     "Alerting",
-			Icon:     "icon-gf icon-gf-alert",
+			SubTitle: "Alert rules & notifications",
+			Id:       "alerting",
+			Icon:     "gicon gicon-alert",
 			Url:      setting.AppSubUrl + "/alerting/list",
 			Children: alertChildNavs,
 		})
 	}
 
-	if c.OrgRole == m.ROLE_ADMIN {
-		data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
-			Text: "Data Sources",
-			Icon: "icon-gf icon-gf-datasources",
-			Url:  setting.AppSubUrl + "/datasources",
-		})
-
-		data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
-			Text: "Plugins",
-			Icon: "icon-gf icon-gf-apps",
-			Url:  setting.AppSubUrl + "/plugins",
-		})
-	}
-
 	enabledPlugins, err := plugins.GetEnabledPlugins(c.OrgId)
 	if err != nil {
 		return nil, err
@@ -140,6 +167,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 		if plugin.Pinned {
 			appLink := &dtos.NavLink{
 				Text: plugin.Name,
+				Id:   "plugin-page-" + plugin.Id,
 				Url:  plugin.DefaultNavUrl,
 				Img:  plugin.Info.Logos.Small,
 			}
@@ -168,29 +196,106 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 
 			if len(appLink.Children) > 0 && c.OrgRole == m.ROLE_ADMIN {
 				appLink.Children = append(appLink.Children, &dtos.NavLink{Divider: true})
-				appLink.Children = append(appLink.Children, &dtos.NavLink{Text: "Plugin Config", Icon: "fa fa-cog", Url: setting.AppSubUrl + "/plugins/" + plugin.Id + "/edit"})
+				appLink.Children = append(appLink.Children, &dtos.NavLink{Text: "Plugin Config", Icon: "gicon gicon-cog", Url: setting.AppSubUrl + "/plugins/" + plugin.Id + "/edit"})
 			}
 
 			if len(appLink.Children) > 0 {
-				data.MainNavLinks = append(data.MainNavLinks, appLink)
+				data.NavTree = append(data.NavTree, appLink)
 			}
 		}
 	}
 
-	if c.IsGrafanaAdmin {
-		data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
-			Text: "Admin",
-			Icon: "fa fa-fw fa-cogs",
-			Url:  setting.AppSubUrl + "/admin",
+	if c.OrgRole == m.ROLE_ADMIN {
+		cfgNode := &dtos.NavLink{
+			Id:       "cfg",
+			Text:     "Configuration",
+			SubTitle: "Organization: " + c.OrgName,
+			Icon:     "gicon gicon-cog",
+			Url:      setting.AppSubUrl + "/datasources",
 			Children: []*dtos.NavLink{
-				{Text: "Global Users", Url: setting.AppSubUrl + "/admin/users"},
-				{Text: "Global Orgs", Url: setting.AppSubUrl + "/admin/orgs"},
-				{Text: "Server Settings", Url: setting.AppSubUrl + "/admin/settings"},
-				{Text: "Server Stats", Url: setting.AppSubUrl + "/admin/stats"},
+				{
+					Text:        "Data Sources",
+					Icon:        "gicon gicon-datasources",
+					Description: "Add and configure data sources",
+					Id:          "datasources",
+					Url:         setting.AppSubUrl + "/datasources",
+				},
+				{
+					Text:        "Users",
+					Id:          "users",
+					Description: "Manage org members",
+					Icon:        "gicon gicon-user",
+					Url:         setting.AppSubUrl + "/org/users",
+				},
+				{
+					Text:        "Teams",
+					Id:          "teams",
+					Description: "Manage org groups",
+					Icon:        "gicon gicon-team",
+					Url:         setting.AppSubUrl + "/org/teams",
+				},
+				{
+					Text:        "Plugins",
+					Id:          "plugins",
+					Description: "View and configure plugins",
+					Icon:        "gicon gicon-plugins",
+					Url:         setting.AppSubUrl + "/plugins",
+				},
+				{
+					Text:        "Preferences",
+					Id:          "org-settings",
+					Description: "Organization preferences",
+					Icon:        "gicon gicon-preferences",
+					Url:         setting.AppSubUrl + "/org",
+				},
+
+				{
+					Text:        "API Keys",
+					Id:          "apikeys",
+					Description: "Create & manage API keys",
+					Icon:        "gicon gicon-apikeys",
+					Url:         setting.AppSubUrl + "/org/apikeys",
+				},
 			},
-		})
+		}
+
+		if c.IsGrafanaAdmin {
+			cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
+				Divider: true, HideFromTabs: true,
+			})
+			cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
+				Text:         "Server Admin",
+				HideFromTabs: true,
+				SubTitle:     "Manage all users & orgs",
+				Id:           "admin",
+				Icon:         "gicon gicon-shield",
+				Url:          setting.AppSubUrl + "/admin/users",
+				Children: []*dtos.NavLink{
+					{Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"},
+					{Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"},
+					{Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"},
+					{Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"},
+					{Text: "Style Guide", Id: "styleguide", Url: setting.AppSubUrl + "/styleguide", Icon: "fa fa-fw fa-eyedropper"},
+				},
+			})
+		}
+
+		data.NavTree = append(data.NavTree, cfgNode)
 	}
 
+	data.NavTree = append(data.NavTree, &dtos.NavLink{
+		Text:         "Help",
+		Id:           "help",
+		Url:          "#",
+		Icon:         "gicon gicon-question",
+		HideFromMenu: true,
+		Children: []*dtos.NavLink{
+			{Text: "Keyboard shortcuts", Url: "/shortcuts", Icon: "fa fa-fw fa-keyboard-o", Target: "_self"},
+			{Text: "Community site", Url: "http://community.grafana.com", Icon: "fa fa-fw fa-comment", Target: "_blank"},
+			{Text: "Documentation", Url: "http://docs.grafana.org", Icon: "fa fa-fw fa-file", Target: "_blank"},
+		},
+	})
+
 	return &data, nil
 }
 

+ 2 - 2
pkg/api/org_invite.go

@@ -61,7 +61,7 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response
 	}
 
 	// send invite email
-	if !inviteDto.SkipEmails && util.IsEmail(inviteDto.LoginOrEmail) {
+	if inviteDto.SendEmail && util.IsEmail(inviteDto.LoginOrEmail) {
 		emailCmd := m.SendEmailCommand{
 			To:       []string{inviteDto.LoginOrEmail},
 			Template: "new_user_invite.html",
@@ -99,7 +99,7 @@ func inviteExistingUserToOrg(c *middleware.Context, user *m.User, inviteDto *dto
 		return ApiError(500, "Error while trying to create org user", err)
 	} else {
 
-		if !inviteDto.SkipEmails && util.IsEmail(user.Email) {
+		if inviteDto.SendEmail && util.IsEmail(user.Email) {
 			emailCmd := m.SendEmailCommand{
 				To:       []string{user.Email},
 				Template: "invited_to_org.html",

+ 5 - 4
pkg/api/org_users.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"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"
@@ -31,10 +32,6 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
 
 	userToAdd := userQuery.Result
 
-	// if userToAdd.Id == c.UserId {
-	// 	return ApiError(400, "Cannot add yourself as user", nil)
-	// }
-
 	cmd.UserId = userToAdd.Id
 
 	if err := bus.Dispatch(&cmd); err != nil {
@@ -64,6 +61,10 @@ func getOrgUsersHelper(orgId int64) Response {
 		return ApiError(500, "Failed to get account user", err)
 	}
 
+	for _, user := range query.Result {
+		user.AvatarUrl = dtos.GetGravatarUrl(user.Email)
+	}
+
 	return Json(200, query.Result)
 }
 

+ 1 - 1
pkg/api/playlist.go

@@ -130,7 +130,7 @@ func GetPlaylistItems(c *middleware.Context) Response {
 func GetPlaylistDashboards(c *middleware.Context) Response {
 	playlistId := c.ParamsInt64(":id")
 
-	playlists, err := LoadPlaylistDashboards(c.OrgId, c.UserId, playlistId)
+	playlists, err := LoadPlaylistDashboards(c.OrgId, c.SignedInUser, playlistId)
 	if err != nil {
 		return ApiError(500, "Could not load dashboards", err)
 	}

+ 9 - 9
pkg/api/playlist_play.go

@@ -34,18 +34,18 @@ func populateDashboardsById(dashboardByIds []int64, dashboardIdOrder map[int64]i
 	return result, nil
 }
 
-func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice {
+func populateDashboardsByTag(orgId int64, signedInUser *m.SignedInUser, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice {
 	result := make(dtos.PlaylistDashboardsSlice, 0)
 
 	if len(dashboardByTag) > 0 {
 		for _, tag := range dashboardByTag {
 			searchQuery := search.Query{
-				Title:     "",
-				Tags:      []string{tag},
-				UserId:    userId,
-				Limit:     100,
-				IsStarred: false,
-				OrgId:     orgId,
+				Title:        "",
+				Tags:         []string{tag},
+				SignedInUser: signedInUser,
+				Limit:        100,
+				IsStarred:    false,
+				OrgId:        orgId,
 			}
 
 			if err := bus.Dispatch(&searchQuery); err == nil {
@@ -64,7 +64,7 @@ func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string, dashb
 	return result
 }
 
-func LoadPlaylistDashboards(orgId, userId, playlistId int64) (dtos.PlaylistDashboardsSlice, error) {
+func LoadPlaylistDashboards(orgId int64, signedInUser *m.SignedInUser, playlistId int64) (dtos.PlaylistDashboardsSlice, error) {
 	playlistItems, _ := LoadPlaylistItems(playlistId)
 
 	dashboardByIds := make([]int64, 0)
@@ -89,7 +89,7 @@ func LoadPlaylistDashboards(orgId, userId, playlistId int64) (dtos.PlaylistDashb
 
 	var k, _ = populateDashboardsById(dashboardByIds, dashboardIdOrder)
 	result = append(result, k...)
-	result = append(result, populateDashboardsByTag(orgId, userId, dashboardByTag, dashboardTagOrder)...)
+	result = append(result, populateDashboardsByTag(orgId, signedInUser, dashboardByTag, dashboardTagOrder)...)
 
 	sort.Sort(result)
 	return result, nil

+ 17 - 2
pkg/api/pluginproxy/ds_proxy.go

@@ -135,9 +135,24 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
 			req.Header.Add("Authorization", dsAuth)
 		}
 
-		// clear cookie headers
+		// clear cookie header, except for whitelisted cookies
+		var keptCookies []*http.Cookie
+		if proxy.ds.JsonData != nil {
+			if keepCookies := proxy.ds.JsonData.Get("keepCookies"); keepCookies != nil {
+				keepCookieNames := keepCookies.MustStringArray()
+				for _, c := range req.Cookies() {
+					for _, v := range keepCookieNames {
+						if c.Name == v {
+							keptCookies = append(keptCookies, c)
+						}
+					}
+				}
+			}
+		}
 		req.Header.Del("Cookie")
-		req.Header.Del("Set-Cookie")
+		for _, c := range keptCookies {
+			req.AddCookie(c)
+		}
 
 		// clear X-Forwarded Host/Port/Proto headers
 		req.Header.Del("X-Forwarded-Host")

+ 52 - 0
pkg/api/pluginproxy/ds_proxy_test.go

@@ -149,6 +149,58 @@ func TestDSRouteRule(t *testing.T) {
 			})
 		})
 
+		Convey("When proxying a data source with no keepCookies specified", func() {
+			plugin := &plugins.DataSourcePlugin{}
+
+			json, _ := simplejson.NewJson([]byte(`{"keepCookies": []}`))
+
+			ds := &m.DataSource{
+				Type:     m.DS_GRAPHITE,
+				Url:      "http://graphite:8086",
+				JsonData: json,
+			}
+
+			ctx := &middleware.Context{}
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+
+			requestUrl, _ := url.Parse("http://grafana.com/sub")
+			req := http.Request{URL: requestUrl, Header: make(http.Header)}
+			cookies := "grafana_user=admin; grafana_remember=99; grafana_sess=11; JSESSION_ID=test"
+			req.Header.Set("Cookie", cookies)
+
+			proxy.getDirector()(&req)
+
+			Convey("Should clear all cookies", func() {
+				So(req.Header.Get("Cookie"), ShouldEqual, "")
+			})
+		})
+
+		Convey("When proxying a data source with keep cookies specified", func() {
+			plugin := &plugins.DataSourcePlugin{}
+
+			json, _ := simplejson.NewJson([]byte(`{"keepCookies": ["JSESSION_ID"]}`))
+
+			ds := &m.DataSource{
+				Type:     m.DS_GRAPHITE,
+				Url:      "http://graphite:8086",
+				JsonData: json,
+			}
+
+			ctx := &middleware.Context{}
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+
+			requestUrl, _ := url.Parse("http://grafana.com/sub")
+			req := http.Request{URL: requestUrl, Header: make(http.Header)}
+			cookies := "grafana_user=admin; grafana_remember=99; grafana_sess=11; JSESSION_ID=test"
+			req.Header.Set("Cookie", cookies)
+
+			proxy.getDirector()(&req)
+
+			Convey("Should keep named cookies", func() {
+				So(req.Header.Get("Cookie"), ShouldEqual, "JSESSION_ID=test")
+			})
+		})
+
 		Convey("When interpolating string", func() {
 			data := templateData{
 				SecureJsonData: map[string]string{

+ 4 - 1
pkg/api/render.go

@@ -21,9 +21,12 @@ func RenderToPng(c *middleware.Context) {
 		Path:     c.Params("*") + queryParams,
 		Width:    queryReader.Get("width", "800"),
 		Height:   queryReader.Get("height", "400"),
-		OrgId:    c.OrgId,
 		Timeout:  queryReader.Get("timeout", "60"),
+		OrgId:    c.OrgId,
+		UserId:   c.UserId,
+		OrgRole:  c.OrgRole,
 		Timezone: queryReader.Get("tz", ""),
+		Encoding: queryReader.Get("encoding", ""),
 	}
 
 	pngPath, err := renderer.RenderToPng(renderOpts)

+ 14 - 3
pkg/api/search.go

@@ -14,27 +14,38 @@ func Search(c *middleware.Context) {
 	tags := c.QueryStrings("tag")
 	starred := c.Query("starred")
 	limit := c.QueryInt("limit")
+	dashboardType := c.Query("type")
 
 	if limit == 0 {
 		limit = 1000
 	}
 
-	dbids := make([]int, 0)
+	dbids := make([]int64, 0)
 	for _, id := range c.QueryStrings("dashboardIds") {
-		dashboardId, err := strconv.Atoi(id)
+		dashboardId, err := strconv.ParseInt(id, 10, 64)
 		if err == nil {
 			dbids = append(dbids, dashboardId)
 		}
 	}
 
+	folderIds := make([]int64, 0)
+	for _, id := range c.QueryStrings("folderIds") {
+		folderId, err := strconv.ParseInt(id, 10, 64)
+		if err == nil {
+			folderIds = append(folderIds, folderId)
+		}
+	}
+
 	searchQuery := search.Query{
 		Title:        query,
 		Tags:         tags,
-		UserId:       c.UserId,
+		SignedInUser: c.SignedInUser,
 		Limit:        limit,
 		IsStarred:    starred == "true",
 		OrgId:        c.OrgId,
 		DashboardIds: dbids,
+		Type:         dashboardType,
+		FolderIds:    folderIds,
 	}
 
 	err := bus.Dispatch(&searchQuery)

+ 92 - 0
pkg/api/team.go

@@ -0,0 +1,92 @@
+package api
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+// POST /api/teams
+func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response {
+	cmd.OrgId = c.OrgId
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamNameTaken {
+			return ApiError(409, "Team name taken", err)
+		}
+		return ApiError(500, "Failed to create Team", err)
+	}
+
+	return Json(200, &util.DynMap{
+		"teamId":  cmd.Result.Id,
+		"message": "Team created",
+	})
+}
+
+// PUT /api/teams/:teamId
+func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
+	cmd.Id = c.ParamsInt64(":teamId")
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamNameTaken {
+			return ApiError(400, "Team name taken", err)
+		}
+		return ApiError(500, "Failed to update Team", err)
+	}
+
+	return ApiSuccess("Team updated")
+}
+
+// DELETE /api/teams/:teamId
+func DeleteTeamById(c *middleware.Context) Response {
+	if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil {
+		if err == m.ErrTeamNotFound {
+			return ApiError(404, "Failed to delete Team. ID not found", nil)
+		}
+		return ApiError(500, "Failed to update Team", err)
+	}
+	return ApiSuccess("Team deleted")
+}
+
+// GET /api/teams/search
+func SearchTeams(c *middleware.Context) Response {
+	perPage := c.QueryInt("perpage")
+	if perPage <= 0 {
+		perPage = 1000
+	}
+	page := c.QueryInt("page")
+	if page < 1 {
+		page = 1
+	}
+
+	query := m.SearchTeamsQuery{
+		Query: c.Query("query"),
+		Name:  c.Query("name"),
+		Page:  page,
+		Limit: perPage,
+		OrgId: c.OrgId,
+	}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to search Teams", err)
+	}
+
+	query.Result.Page = page
+	query.Result.PerPage = perPage
+
+	return Json(200, query.Result)
+}
+
+// GET /api/teams/:teamId
+func GetTeamById(c *middleware.Context) Response {
+	query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		if err == m.ErrTeamNotFound {
+			return ApiError(404, "Team not found", err)
+		}
+
+		return ApiError(500, "Failed to get Team", err)
+	}
+
+	return Json(200, &query.Result)
+}

+ 44 - 0
pkg/api/team_members.go

@@ -0,0 +1,44 @@
+package api
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+// GET /api/teams/:teamId/members
+func GetTeamMembers(c *middleware.Context) Response {
+	query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to get Team Members", err)
+	}
+
+	return Json(200, query.Result)
+}
+
+// POST /api/teams/:teamId/members
+func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response {
+	cmd.TeamId = c.ParamsInt64(":teamId")
+	cmd.OrgId = c.OrgId
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamMemberAlreadyAdded {
+			return ApiError(400, "User is already added to this team", err)
+		}
+		return ApiError(500, "Failed to add Member to Team", err)
+	}
+
+	return Json(200, &util.DynMap{
+		"message": "Member added to Team",
+	})
+}
+
+// DELETE /api/teams/:teamId/members/:userId
+func RemoveTeamMember(c *middleware.Context) Response {
+	if err := bus.Dispatch(&m.RemoveTeamMemberCommand{TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
+		return ApiError(500, "Failed to remove Member from Team", err)
+	}
+	return ApiSuccess("Team Member removed")
+}

+ 71 - 0
pkg/api/team_test.go

@@ -0,0 +1,71 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestTeamApiEndpoint(t *testing.T) {
+	Convey("Given two teams", t, func() {
+		mockResult := models.SearchTeamQueryResult{
+			Teams: []*models.SearchTeamDto{
+				{Name: "team1"},
+				{Name: "team2"},
+			},
+			TotalCount: 2,
+		}
+
+		Convey("When searching with no parameters", func() {
+			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
+				var sentLimit int
+				var sendPage int
+				bus.AddHandler("test", func(query *models.SearchTeamsQuery) error {
+					query.Result = mockResult
+
+					sentLimit = query.Limit
+					sendPage = query.Page
+
+					return nil
+				})
+
+				sc.handlerFunc = SearchTeams
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				So(sentLimit, ShouldEqual, 1000)
+				So(sendPage, ShouldEqual, 1)
+
+				respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+				So(err, ShouldBeNil)
+
+				So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2)
+				So(len(respJSON.Get("teams").MustArray()), ShouldEqual, 2)
+			})
+		})
+
+		Convey("When searching with page and perpage parameters", func() {
+			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
+				var sentLimit int
+				var sendPage int
+				bus.AddHandler("test", func(query *models.SearchTeamsQuery) error {
+					query.Result = mockResult
+
+					sentLimit = query.Limit
+					sendPage = query.Page
+
+					return nil
+				})
+
+				sc.handlerFunc = SearchTeams
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
+
+				So(sentLimit, ShouldEqual, 10)
+				So(sendPage, ShouldEqual, 2)
+			})
+		})
+	})
+}

+ 6 - 1
pkg/api/user.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"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"
@@ -219,7 +220,7 @@ func SearchUsers(c *middleware.Context) Response {
 	return Json(200, query.Result.Users)
 }
 
-// GET /api/search
+// GET /api/users/search
 func SearchUsersWithPaging(c *middleware.Context) Response {
 	query, err := searchUser(c)
 	if err != nil {
@@ -247,6 +248,10 @@ func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) {
 		return nil, err
 	}
 
+	for _, user := range query.Result.Users {
+		user.AvatarUrl = dtos.GetGravatarUrl(user.Email)
+	}
+
 	query.Result.Page = page
 	query.Result.PerPage = perPage
 

+ 1 - 1
pkg/cmd/grafana-server/main.go

@@ -30,7 +30,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/tsdb/testdata"
 )
 
-var version = "4.6.0"
+var version = "5.0.0"
 var commit = "NA"
 var buildstamp string
 var build_date string

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

@@ -16,17 +16,22 @@ import (
 
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
 
 type RenderOpts struct {
-	Path     string
-	Width    string
-	Height   string
-	Timeout  string
-	OrgId    int64
-	Timezone string
+	Path           string
+	Width          string
+	Height         string
+	Timeout        string
+	OrgId          int64
+	UserId         int64
+	OrgRole        models.RoleType
+	Timezone       string
+	IsAlertContext bool
+	Encoding       string
 }
 
 var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
@@ -74,7 +79,11 @@ func RenderToPng(params *RenderOpts) (string, error) {
 	pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20)))
 	pngPath = pngPath + ".png"
 
-	renderKey := middleware.AddRenderAuthKey(params.OrgId)
+	orgRole := params.OrgRole
+	if params.IsAlertContext {
+		orgRole = models.ROLE_ADMIN
+	}
+	renderKey := middleware.AddRenderAuthKey(params.OrgId, params.UserId, orgRole)
 	defer middleware.RemoveRenderAuthKey(renderKey)
 
 	timeout, err := strconv.Atoi(params.Timeout)
@@ -95,6 +104,10 @@ func RenderToPng(params *RenderOpts) (string, error) {
 		"renderKey=" + renderKey,
 	}
 
+	if params.Encoding != "" {
+		cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", params.Encoding)}, cmdArgs...)
+	}
+
 	cmd := exec.Command(binPath, cmdArgs...)
 	stdout, err := cmd.StdoutPipe()
 

+ 1 - 1
pkg/middleware/middleware.go

@@ -87,7 +87,7 @@ func initContextWithAnonymousUser(ctx *Context) bool {
 
 	ctx.IsSignedIn = false
 	ctx.AllowAnonymous = true
-	ctx.SignedInUser = &m.SignedInUser{}
+	ctx.SignedInUser = &m.SignedInUser{IsAnonymous: true}
 	ctx.OrgRole = m.RoleType(setting.AnonymousOrgRole)
 	ctx.OrgId = orgQuery.Result.Id
 	ctx.OrgName = orgQuery.Result.Name

+ 3 - 2
pkg/middleware/render_auth.go

@@ -33,14 +33,15 @@ func initContextWithRenderAuth(ctx *Context) bool {
 
 type renderContextFunc func(key string) (string, error)
 
-func AddRenderAuthKey(orgId int64) string {
+func AddRenderAuthKey(orgId int64, userId int64, orgRole m.RoleType) string {
 	renderKeysLock.Lock()
 
 	key := util.GetRandomString(32)
 
 	renderKeys[key] = &m.SignedInUser{
 		OrgId:   orgId,
-		OrgRole: m.ROLE_VIEWER,
+		OrgRole: orgRole,
+		UserId:  userId,
 	}
 
 	renderKeysLock.Unlock()

+ 95 - 0
pkg/models/dashboard_acl.go

@@ -0,0 +1,95 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+type PermissionType int
+
+const (
+	PERMISSION_VIEW PermissionType = 1 << iota
+	PERMISSION_EDIT
+	PERMISSION_ADMIN
+)
+
+func (p PermissionType) String() string {
+	names := map[int]string{
+		int(PERMISSION_VIEW):  "View",
+		int(PERMISSION_EDIT):  "Edit",
+		int(PERMISSION_ADMIN): "Admin",
+	}
+	return names[int(p)]
+}
+
+// Typed errors
+var (
+	ErrDashboardAclInfoMissing           = errors.New("User id and team id cannot both be empty for a dashboard permission.")
+	ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.")
+)
+
+// Dashboard ACL model
+type DashboardAcl struct {
+	Id          int64
+	OrgId       int64
+	DashboardId int64
+
+	UserId     int64
+	TeamId     int64
+	Role       *RoleType // pointer to be nullable
+	Permission PermissionType
+
+	Created time.Time
+	Updated time.Time
+}
+
+type DashboardAclInfoDTO struct {
+	Id          int64 `json:"id"`
+	OrgId       int64 `json:"-"`
+	DashboardId int64 `json:"dashboardId"`
+
+	Created time.Time `json:"created"`
+	Updated time.Time `json:"updated"`
+
+	UserId         int64          `json:"userId"`
+	UserLogin      string         `json:"userLogin"`
+	UserEmail      string         `json:"userEmail"`
+	TeamId         int64          `json:"teamId"`
+	Team           string         `json:"team"`
+	Role           *RoleType      `json:"role,omitempty"`
+	Permission     PermissionType `json:"permission"`
+	PermissionName string         `json:"permissionName"`
+}
+
+//
+// COMMANDS
+//
+
+type UpdateDashboardAclCommand struct {
+	DashboardId int64
+	Items       []*DashboardAcl
+}
+
+type SetDashboardAclCommand struct {
+	DashboardId int64
+	OrgId       int64
+	UserId      int64
+	TeamId      int64
+	Permission  PermissionType
+
+	Result DashboardAcl
+}
+
+type RemoveDashboardAclCommand struct {
+	AclId int64
+	OrgId int64
+}
+
+//
+// QUERIES
+//
+type GetDashboardAclInfoListQuery struct {
+	DashboardId int64
+	OrgId       int64
+	Result      []*DashboardAclInfoDTO
+}

+ 21 - 0
pkg/models/dashboard_acl_test.go

@@ -0,0 +1,21 @@
+package models
+
+import (
+	"testing"
+
+	"fmt"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDashboardAclModel(t *testing.T) {
+
+	Convey("When printing a PermissionType", t, func() {
+		view := PERMISSION_VIEW
+		printed := fmt.Sprint(view)
+
+		Convey("Should output a friendly name", func() {
+			So(printed, ShouldEqual, "View")
+		})
+	})
+}

+ 18 - 1
pkg/models/dashboards.go

@@ -16,6 +16,7 @@ var (
 	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")
 )
@@ -49,6 +50,9 @@ type Dashboard struct {
 
 	UpdatedBy int64
 	CreatedBy int64
+	FolderId  int64
+	IsFolder  bool
+	HasAcl    bool
 
 	Title string
 	Data  *simplejson.Json
@@ -66,6 +70,15 @@ func NewDashboard(title string) *Dashboard {
 	return dash
 }
 
+// NewDashboardFolder creates a new dashboard folder
+func NewDashboardFolder(title string) *Dashboard {
+	folder := NewDashboard(title)
+	folder.Data.Set("schemaVersion", 16)
+	folder.Data.Set("editable", true)
+	folder.Data.Set("hideControls", true)
+	return folder
+}
+
 // GetTags turns the tags in data json into go string array
 func (dash *Dashboard) GetTags() []string {
 	return dash.Data.Get("tags").MustStringArray()
@@ -113,6 +126,8 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
 	dash.UpdatedBy = userId
 	dash.OrgId = cmd.OrgId
 	dash.PluginId = cmd.PluginId
+	dash.IsFolder = cmd.IsFolder
+	dash.FolderId = cmd.FolderId
 	dash.UpdateSlug()
 	return dash
 }
@@ -140,6 +155,8 @@ type SaveDashboardCommand struct {
 	OrgId        int64            `json:"-"`
 	RestoredFrom int              `json:"-"`
 	PluginId     string           `json:"-"`
+	FolderId     int64            `json:"folderId"`
+	IsFolder     bool             `json:"isFolder"`
 
 	UpdatedAt time.Time
 
@@ -147,7 +164,7 @@ type SaveDashboardCommand struct {
 }
 
 type DeleteDashboardCommand struct {
-	Slug  string
+	Id    int64
 	OrgId int64
 }
 

+ 23 - 0
pkg/models/dashboards_test.go

@@ -28,4 +28,27 @@ func TestDashboardModel(t *testing.T) {
 		})
 	})
 
+	Convey("Given a new dashboard folder", t, func() {
+		json := simplejson.New()
+		json.Set("title", "test dash")
+
+		cmd := &SaveDashboardCommand{Dashboard: json, IsFolder: true}
+		dash := cmd.GetDashboardModel()
+
+		Convey("Should set IsFolder to true", func() {
+			So(dash.IsFolder, ShouldBeTrue)
+		})
+	})
+
+	Convey("Given a child dashboard", t, func() {
+		json := simplejson.New()
+		json.Set("title", "test dash")
+
+		cmd := &SaveDashboardCommand{Dashboard: json, FolderId: 1}
+		dash := cmd.GetDashboardModel()
+
+		Convey("Should set FolderId", func() {
+			So(dash.FolderId, ShouldEqual, 1)
+		})
+	})
 }

+ 8 - 7
pkg/models/org_user.go

@@ -18,25 +18,25 @@ var (
 type RoleType string
 
 const (
-	ROLE_VIEWER           RoleType = "Viewer"
-	ROLE_EDITOR           RoleType = "Editor"
-	ROLE_READ_ONLY_EDITOR RoleType = "Read Only Editor"
-	ROLE_ADMIN            RoleType = "Admin"
+	ROLE_VIEWER RoleType = "Viewer"
+	ROLE_EDITOR RoleType = "Editor"
+	ROLE_ADMIN  RoleType = "Admin"
 )
 
 func (r RoleType) IsValid() bool {
-	return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR
+	return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR
 }
 
 func (r RoleType) Includes(other RoleType) bool {
 	if r == ROLE_ADMIN {
 		return true
 	}
-	if r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR {
+
+	if r == ROLE_EDITOR {
 		return other != ROLE_ADMIN
 	}
 
-	return r == other
+	return false
 }
 
 func (r *RoleType) UnmarshalJSON(data []byte) error {
@@ -106,6 +106,7 @@ type OrgUserDTO struct {
 	OrgId         int64     `json:"orgId"`
 	UserId        int64     `json:"userId"`
 	Email         string    `json:"email"`
+	AvatarUrl     string    `json:"avatarUrl"`
 	Login         string    `json:"login"`
 	Role          string    `json:"role"`
 	LastSeenAt    time.Time `json:"lastSeenAt"`

+ 75 - 0
pkg/models/team.go

@@ -0,0 +1,75 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrTeamNotFound  = errors.New("Team not found")
+	ErrTeamNameTaken = errors.New("Team name is taken")
+)
+
+// Team model
+type Team struct {
+	Id    int64  `json:"id"`
+	OrgId int64  `json:"orgId"`
+	Name  string `json:"name"`
+
+	Created time.Time `json:"created"`
+	Updated time.Time `json:"updated"`
+}
+
+// ---------------------
+// COMMANDS
+
+type CreateTeamCommand struct {
+	Name  string `json:"name" binding:"Required"`
+	OrgId int64  `json:"-"`
+
+	Result Team `json:"-"`
+}
+
+type UpdateTeamCommand struct {
+	Id   int64
+	Name string
+}
+
+type DeleteTeamCommand struct {
+	Id int64
+}
+
+type GetTeamByIdQuery struct {
+	Id     int64
+	Result *Team
+}
+
+type GetTeamsByUserQuery struct {
+	UserId int64   `json:"userId"`
+	Result []*Team `json:"teams"`
+}
+
+type SearchTeamsQuery struct {
+	Query string
+	Name  string
+	Limit int
+	Page  int
+	OrgId int64
+
+	Result SearchTeamQueryResult
+}
+
+type SearchTeamDto struct {
+	Id          int64  `json:"id"`
+	OrgId       int64  `json:"orgId"`
+	Name        string `json:"name"`
+	MemberCount int64  `json:"memberCount"`
+}
+
+type SearchTeamQueryResult struct {
+	TotalCount int64            `json:"totalCount"`
+	Teams      []*SearchTeamDto `json:"teams"`
+	Page       int              `json:"page"`
+	PerPage    int              `json:"perPage"`
+}

+ 55 - 0
pkg/models/team_member.go

@@ -0,0 +1,55 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrTeamMemberAlreadyAdded = errors.New("User is already added to this team")
+)
+
+// TeamMember model
+type TeamMember struct {
+	Id     int64
+	OrgId  int64
+	TeamId int64
+	UserId int64
+
+	Created time.Time
+	Updated time.Time
+}
+
+// ---------------------
+// COMMANDS
+
+type AddTeamMemberCommand struct {
+	UserId int64 `json:"userId" binding:"Required"`
+	OrgId  int64 `json:"-"`
+	TeamId int64 `json:"-"`
+}
+
+type RemoveTeamMemberCommand struct {
+	UserId int64
+	TeamId int64
+}
+
+// ----------------------
+// QUERIES
+
+type GetTeamMembersQuery struct {
+	TeamId int64
+	Result []*TeamMemberDTO
+}
+
+// ----------------------
+// Projections and DTOs
+
+type TeamMemberDTO struct {
+	OrgId  int64  `json:"orgId"`
+	TeamId int64  `json:"teamId"`
+	UserId int64  `json:"userId"`
+	Email  string `json:"email"`
+	Login  string `json:"login"`
+}

+ 21 - 0
pkg/models/user.go

@@ -160,7 +160,9 @@ type SignedInUser struct {
 	Name           string
 	Email          string
 	ApiKeyId       int64
+	OrgCount       int
 	IsGrafanaAdmin bool
+	IsAnonymous    bool
 	HelpFlags1     HelpFlags1
 	LastSeenAt     time.Time
 }
@@ -169,10 +171,28 @@ func (u *SignedInUser) ShouldUpdateLastSeenAt() bool {
 	return u.UserId > 0 && time.Since(u.LastSeenAt) > time.Minute*5
 }
 
+func (u *SignedInUser) NameOrFallback() string {
+	if u.Name != "" {
+		return u.Name
+	} else if u.Login != "" {
+		return u.Login
+	} else {
+		return u.Email
+	}
+}
+
 type UpdateUserLastSeenAtCommand struct {
 	UserId int64
 }
 
+func (user *SignedInUser) HasRole(role RoleType) bool {
+	if user.IsGrafanaAdmin {
+		return true
+	}
+
+	return user.OrgRole.Includes(role)
+}
+
 type UserProfileDTO struct {
 	Id             int64  `json:"id"`
 	Email          string `json:"email"`
@@ -188,6 +208,7 @@ type UserSearchHitDTO struct {
 	Name          string    `json:"name"`
 	Login         string    `json:"login"`
 	Email         string    `json:"email"`
+	AvatarUrl     string    `json:"avatarUrl"`
 	IsAdmin       bool      `json:"isAdmin"`
 	LastSeenAt    time.Time `json:"lastSeenAt"`
 	LastSeenAtAge string    `json:"lastSeenAtAge"`

+ 1 - 0
pkg/plugins/dashboard_importer.go

@@ -69,6 +69,7 @@ func ImportDashboard(cmd *ImportDashboardCommand) error {
 		UserId:    cmd.UserId,
 		Overwrite: cmd.Overwrite,
 		PluginId:  cmd.PluginId,
+		FolderId:  dashboard.FolderId,
 	}
 
 	if err := bus.Dispatch(&saveCmd); err != nil {

+ 26 - 20
pkg/plugins/dashboard_importer_test.go

@@ -13,16 +13,9 @@ import (
 )
 
 func TestDashboardImport(t *testing.T) {
-
-	Convey("When importing plugin dashboard", t, func() {
-		setting.Cfg = ini.Empty()
-		sec, _ := setting.Cfg.NewSection("plugin.test-app")
-		sec.NewKey("path", "../../tests/test-app")
-		err := Init()
-
-		So(err, ShouldBeNil)
-
+	pluginScenario("When importing a plugin dashboard", t, func() {
 		var importedDash *m.Dashboard
+
 		bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error {
 			importedDash = cmd.GetDashboardModel()
 			cmd.Result = importedDash
@@ -39,7 +32,7 @@ func TestDashboardImport(t *testing.T) {
 			},
 		}
 
-		err = ImportDashboard(&cmd)
+		err := ImportDashboard(&cmd)
 		So(err, ShouldBeNil)
 
 		Convey("should install dashboard", func() {
@@ -59,16 +52,16 @@ func TestDashboardImport(t *testing.T) {
 
 	Convey("When evaling dashboard template", t, func() {
 		template, _ := simplejson.NewJson([]byte(`{
-      "__inputs": [
-        {
-					"name": "DS_NAME",
-          "type": "datasource"
-        }
-      ],
-      "test": {
-        "prop": "${DS_NAME}"
-      }
-    }`))
+		"__inputs": [
+			{
+						"name": "DS_NAME",
+			"type": "datasource"
+			}
+		],
+		"test": {
+			"prop": "${DS_NAME}"
+		}
+		}`))
 
 		evaluator := &DashTemplateEvaluator{
 			template: template,
@@ -92,3 +85,16 @@ func TestDashboardImport(t *testing.T) {
 	})
 
 }
+
+func pluginScenario(desc string, t *testing.T, fn func()) {
+	Convey("Given a plugin", t, func() {
+		setting.Cfg = ini.Empty()
+		sec, _ := setting.Cfg.NewSection("plugin.test-app")
+		sec.NewKey("path", "../../tests/test-app")
+		err := Init()
+
+		So(err, ShouldBeNil)
+
+		Convey(desc, fn)
+	})
+}

+ 5 - 2
pkg/plugins/dashboards.go

@@ -15,6 +15,7 @@ type PluginDashboardInfoDTO struct {
 	Imported         bool   `json:"imported"`
 	ImportedUri      string `json:"importedUri"`
 	Slug             string `json:"slug"`
+	DashboardId      int64  `json:"dashboardId"`
 	ImportedRevision int64  `json:"importedRevision"`
 	Revision         int64  `json:"revision"`
 	Description      string `json:"description"`
@@ -60,6 +61,7 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
 		// find existing dashboard
 		for _, existingDash := range query.Result {
 			if existingDash.Slug == dashboard.Slug {
+				res.DashboardId = existingDash.Id
 				res.Imported = true
 				res.ImportedUri = "db/" + existingDash.Slug
 				res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
@@ -74,8 +76,9 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
 	for _, dash := range query.Result {
 		if _, exists := existingMatches[dash.Id]; !exists {
 			result = append(result, &PluginDashboardInfoDTO{
-				Slug:    dash.Slug,
-				Removed: true,
+				Slug:        dash.Slug,
+				DashboardId: dash.Id,
+				Removed:     true,
 			})
 		}
 	}

+ 2 - 2
pkg/plugins/dashboards_updater.go

@@ -75,7 +75,7 @@ func syncPluginDashboards(pluginDef *PluginBase, orgId int64) {
 		if dash.Removed {
 			plog.Info("Deleting plugin dashboard", "pluginId", pluginDef.Id, "dashboard", dash.Slug)
 
-			deleteCmd := m.DeleteDashboardCommand{OrgId: orgId, Slug: dash.Slug}
+			deleteCmd := m.DeleteDashboardCommand{OrgId: orgId, Id: dash.DashboardId}
 			if err := bus.Dispatch(&deleteCmd); err != nil {
 				plog.Error("Failed to auto update app dashboard", "pluginId", pluginDef.Id, "error", err)
 				return
@@ -124,7 +124,7 @@ func handlePluginStateChanged(event *m.PluginStateChangedEvent) error {
 			return err
 		} else {
 			for _, dash := range query.Result {
-				deleteCmd := m.DeleteDashboardCommand{OrgId: dash.OrgId, Slug: dash.Slug}
+				deleteCmd := m.DeleteDashboardCommand{OrgId: dash.OrgId, Id: dash.Id}
 
 				plog.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
 

+ 0 - 8
pkg/services/alerting/eval_context.go

@@ -75,14 +75,6 @@ func (c *EvalContext) ShouldUpdateAlertState() bool {
 	return c.Rule.State != c.PrevAlertState
 }
 
-func (c *EvalContext) ShouldSendNotification() bool {
-	if (c.PrevAlertState == m.AlertStatePending) && (c.Rule.State == m.AlertStateOK) {
-		return false
-	}
-
-	return true
-}
-
 func (a *EvalContext) GetDurationMs() float64 {
 	return float64(a.EndTime.Nanosecond()-a.StartTime.Nanosecond()) / float64(1000000)
 }

+ 0 - 16
pkg/services/alerting/eval_context_test.go

@@ -28,21 +28,5 @@ func TestAlertingEvalContext(t *testing.T) {
 				So(ctx.ShouldUpdateAlertState(), ShouldBeFalse)
 			})
 		})
-
-		Convey("Should send notifications", func() {
-			Convey("pending -> ok", func() {
-				ctx.PrevAlertState = models.AlertStatePending
-				ctx.Rule.State = models.AlertStateOK
-
-				So(ctx.ShouldSendNotification(), ShouldBeFalse)
-			})
-
-			Convey("ok -> alerting", func() {
-				ctx.PrevAlertState = models.AlertStateOK
-				ctx.Rule.State = models.AlertStateAlerting
-
-				So(ctx.ShouldSendNotification(), ShouldBeTrue)
-			})
-		})
 	})
 }

+ 5 - 0
pkg/services/alerting/eval_handler.go

@@ -39,6 +39,11 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) {
 			break
 		}
 
+		if i == 0 {
+			firing = cr.Firing
+			noDataFound = cr.NoDataFound
+		}
+
 		// calculating Firing based on operator
 		if cr.Operator == "or" {
 			firing = firing || cr.Firing

+ 37 - 0
pkg/services/alerting/eval_handler_test.go

@@ -36,6 +36,16 @@ func TestAlertingEvaluationHandler(t *testing.T) {
 			So(context.ConditionEvals, ShouldEqual, "true = true")
 		})
 
+		Convey("Show return triggered with single passing condition2", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{&conditionStub{firing: true, operator: "and"}},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, true)
+			So(context.ConditionEvals, ShouldEqual, "true = true")
+		})
+
 		Convey("Show return false with not passing asdf", func() {
 			context := NewEvalContext(context.TODO(), &Rule{
 				Conditions: []Condition{
@@ -131,6 +141,33 @@ func TestAlertingEvaluationHandler(t *testing.T) {
 			So(context.ConditionEvals, ShouldEqual, "[[true OR false] OR true] = true")
 		})
 
+		Convey("Should return false if no condition is firing using OR operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: false, operator: "or"},
+					&conditionStub{firing: false, operator: "or"},
+					&conditionStub{firing: false, operator: "or"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, false)
+			So(context.ConditionEvals, ShouldEqual, "[[false OR false] OR false] = false")
+		})
+
+		Convey("Should retuasdfrn no data if one condition has nodata", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{operator: "or", noData: false},
+					&conditionStub{operator: "or", noData: false},
+					&conditionStub{operator: "or", noData: false},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.NoDataFound, ShouldBeFalse)
+		})
+
 		Convey("Should return no data if one condition has nodata", func() {
 			context := NewEvalContext(context.TODO(), &Rule{
 				Conditions: []Condition{

+ 1 - 1
pkg/services/alerting/interfaces.go

@@ -15,7 +15,7 @@ type Notifier interface {
 	Notify(evalContext *EvalContext) error
 	GetType() string
 	NeedsImage() bool
-	PassesFilter(rule *Rule) bool
+	ShouldNotify(evalContext *EvalContext) bool
 
 	GetNotifierId() int64
 	GetIsDefault() bool

+ 11 - 24
pkg/services/alerting/notifier.go

@@ -24,7 +24,7 @@ type NotifierPlugin struct {
 }
 
 type NotificationService interface {
-	Send(context *EvalContext) error
+	SendIfNeeded(context *EvalContext) error
 }
 
 func NewNotificationService() NotificationService {
@@ -41,14 +41,12 @@ func newNotificationService() *notificationService {
 	}
 }
 
-func (n *notificationService) Send(context *EvalContext) error {
-	notifiers, err := n.getNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
+func (n *notificationService) SendIfNeeded(context *EvalContext) error {
+	notifiers, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
 	if err != nil {
 		return err
 	}
 
-	n.log.Info("Sending notifications for", "ruleId", context.Rule.Id, "sent count", len(notifiers))
-
 	if len(notifiers) == 0 {
 		return nil
 	}
@@ -67,7 +65,7 @@ func (n *notificationService) sendNotifications(context *EvalContext, notifiers
 
 	for _, notifier := range notifiers {
 		not := notifier //avoid updating scope variable in go routine
-		n.log.Info("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
+		n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
 		metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
 		g.Go(func() error { return not.Notify(context) })
 	}
@@ -82,10 +80,11 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
 	}
 
 	renderOpts := &renderer.RenderOpts{
-		Width:   "800",
-		Height:  "400",
-		Timeout: "30",
-		OrgId:   context.Rule.OrgId,
+		Width:          "800",
+		Height:         "400",
+		Timeout:        "30",
+		OrgId:          context.Rule.OrgId,
+		IsAlertContext: true,
 	}
 
 	if slug, err := context.GetDashboardSlug(); err != nil {
@@ -109,7 +108,7 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
 	return nil
 }
 
-func (n *notificationService) getNotifiers(orgId int64, notificationIds []int64, context *EvalContext) (NotifierSlice, error) {
+func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, context *EvalContext) (NotifierSlice, error) {
 	query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds}
 
 	if err := bus.Dispatch(query); err != nil {
@@ -121,7 +120,7 @@ func (n *notificationService) getNotifiers(orgId int64, notificationIds []int64,
 		if not, err := n.createNotifierFor(notification); err != nil {
 			return nil, err
 		} else {
-			if shouldUseNotification(not, context) {
+			if not.ShouldNotify(context) {
 				result = append(result, not)
 			}
 		}
@@ -139,18 +138,6 @@ func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Not
 	return notifierPlugin.Factory(model)
 }
 
-func shouldUseNotification(notifier Notifier, context *EvalContext) bool {
-	if !context.Firing {
-		return true
-	}
-
-	if context.Error != nil {
-		return true
-	}
-
-	return notifier.PassesFilter(context.Rule)
-}
-
 type NotifierFactory func(notification *m.AlertNotification) (Notifier, error)
 
 var notifierFactories map[string]*NotifierPlugin = make(map[string]*NotifierPlugin)

+ 0 - 89
pkg/services/alerting/notifier_test.go

@@ -1,89 +0,0 @@
-package alerting
-
-import (
-	"testing"
-
-	"fmt"
-
-	"github.com/grafana/grafana/pkg/models"
-	m "github.com/grafana/grafana/pkg/models"
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-type FakeNotifier struct {
-	FakeMatchResult bool
-}
-
-func (fn *FakeNotifier) GetType() string {
-	return "FakeNotifier"
-}
-
-func (fn *FakeNotifier) NeedsImage() bool {
-	return true
-}
-
-func (n *FakeNotifier) GetNotifierId() int64 {
-	return 0
-}
-
-func (n *FakeNotifier) GetIsDefault() bool {
-	return false
-}
-
-func (fn *FakeNotifier) Notify(alertResult *EvalContext) error { return nil }
-
-func (fn *FakeNotifier) PassesFilter(rule *Rule) bool {
-	return fn.FakeMatchResult
-}
-
-func TestAlertNotificationExtraction(t *testing.T) {
-
-	Convey("Notifier tests", t, func() {
-		Convey("none firing alerts", func() {
-			ctx := &EvalContext{
-				Firing: false,
-				Rule: &Rule{
-					State: m.AlertStateAlerting,
-				},
-			}
-			notifier := &FakeNotifier{FakeMatchResult: false}
-
-			So(shouldUseNotification(notifier, ctx), ShouldBeTrue)
-		})
-
-		Convey("execution error cannot be ignored", func() {
-			ctx := &EvalContext{
-				Firing: true,
-				Error:  fmt.Errorf("I used to be a programmer just like you"),
-				Rule: &Rule{
-					State: m.AlertStateOK,
-				},
-			}
-			notifier := &FakeNotifier{FakeMatchResult: false}
-
-			So(shouldUseNotification(notifier, ctx), ShouldBeTrue)
-		})
-
-		Convey("firing alert that match", func() {
-			ctx := &EvalContext{
-				Firing: true,
-				Rule: &Rule{
-					State: models.AlertStateAlerting,
-				},
-			}
-			notifier := &FakeNotifier{FakeMatchResult: true}
-
-			So(shouldUseNotification(notifier, ctx), ShouldBeTrue)
-		})
-
-		Convey("firing alert that dont match", func() {
-			ctx := &EvalContext{
-				Firing: true,
-				Rule:   &Rule{State: m.AlertStateOK},
-			}
-			notifier := &FakeNotifier{FakeMatchResult: false}
-
-			So(shouldUseNotification(notifier, ctx), ShouldBeFalse)
-		})
-	})
-}

+ 96 - 0
pkg/services/alerting/notifiers/alertmanager.go

@@ -0,0 +1,96 @@
+package notifiers
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
+)
+
+func init() {
+	alerting.RegisterNotifier(&alerting.NotifierPlugin{
+		Type:        "prometheus-alertmanager",
+		Name:        "Prometheus Alertmanager",
+		Description: "Sends alert to Prometheus Alertmanager",
+		Factory:     NewAlertmanagerNotifier,
+		OptionsTemplate: `
+      <h3 class="page-heading">Alertmanager settings</h3>
+      <div class="gf-form">
+        <span class="gf-form-label width-10">Url</span>
+        <input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url" placeholder="http://localhost:9093"></input>
+      </div>
+    `,
+	})
+}
+
+func NewAlertmanagerNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
+	url := model.Settings.Get("url").MustString()
+	if url == "" {
+		return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
+	}
+
+	return &AlertmanagerNotifier{
+		NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
+		Url:          url,
+		log:          log.New("alerting.notifier.prometheus-alertmanager"),
+	}, nil
+}
+
+type AlertmanagerNotifier struct {
+	NotifierBase
+	Url string
+	log log.Logger
+}
+
+func (this *AlertmanagerNotifier) ShouldNotify(evalContext *alerting.EvalContext) bool {
+	return evalContext.Rule.State == m.AlertStateAlerting
+}
+
+func (this *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error {
+
+	alerts := make([]interface{}, 0)
+	for _, match := range evalContext.EvalMatches {
+		alertJSON := simplejson.New()
+		alertJSON.Set("startsAt", evalContext.StartTime.UTC().Format(time.RFC3339))
+
+		if ruleUrl, err := evalContext.GetRuleUrl(); err == nil {
+			alertJSON.Set("generatorURL", ruleUrl)
+		}
+
+		if evalContext.Rule.Message != "" {
+			alertJSON.SetPath([]string{"annotations", "description"}, evalContext.Rule.Message)
+		}
+
+		tags := make(map[string]string)
+		if len(match.Tags) == 0 {
+			tags["metric"] = match.Metric
+		} else {
+			for k, v := range match.Tags {
+				tags[k] = v
+			}
+		}
+		tags["alertname"] = evalContext.Rule.Name
+		alertJSON.Set("labels", tags)
+
+		alerts = append(alerts, alertJSON)
+	}
+
+	bodyJSON := simplejson.NewFromAny(alerts)
+	body, _ := bodyJSON.MarshalJSON()
+
+	cmd := &m.SendWebhookSync{
+		Url:        this.Url + "/api/v1/alerts",
+		HttpMethod: "POST",
+		Body:       string(body),
+	}
+
+	if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
+		this.log.Error("Failed to send alertmanager", "error", err, "alertmanager", this.Name)
+		return err
+	}
+
+	return nil
+}

+ 47 - 0
pkg/services/alerting/notifiers/alertmanager_test.go

@@ -0,0 +1,47 @@
+package notifiers
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAlertmanagerNotifier(t *testing.T) {
+	Convey("Alertmanager notifier tests", t, func() {
+
+		Convey("Parsing alert notification from settings", func() {
+			Convey("empty settings should return error", func() {
+				json := `{ }`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "alertmanager",
+					Type:     "alertmanager",
+					Settings: settingsJSON,
+				}
+
+				_, err := NewAlertmanagerNotifier(model)
+				So(err, ShouldNotBeNil)
+			})
+
+			Convey("from settings", func() {
+				json := `{ "url": "http://127.0.0.1:9093/" }`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "alertmanager",
+					Type:     "alertmanager",
+					Settings: settingsJSON,
+				}
+
+				not, err := NewAlertmanagerNotifier(model)
+				alertmanagerNotifier := not.(*AlertmanagerNotifier)
+
+				So(err, ShouldBeNil)
+				So(alertmanagerNotifier.Url, ShouldEqual, "http://127.0.0.1:9093/")
+			})
+		})
+	})
+}

+ 9 - 2
pkg/services/alerting/notifiers/base.go

@@ -2,6 +2,7 @@ package notifiers
 
 import (
 	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/alerting"
 )
 
@@ -14,7 +15,7 @@ type NotifierBase struct {
 }
 
 func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase {
-	uploadImage := model.Get("uploadImage").MustBool(true)
+	uploadImage := model.Get("uploadImage").MustBool(false)
 
 	return NotifierBase{
 		Id:          id,
@@ -25,7 +26,13 @@ func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model
 	}
 }
 
-func (n *NotifierBase) PassesFilter(rule *alerting.Rule) bool {
+func defaultShouldNotify(context *alerting.EvalContext) bool {
+	if context.PrevAlertState == context.Rule.State {
+		return false
+	}
+	if (context.PrevAlertState == m.AlertStatePending) && (context.Rule.State == m.AlertStateOK) {
+		return false
+	}
 	return true
 }
 

+ 32 - 0
pkg/services/alerting/notifiers/base_test.go

@@ -0,0 +1,32 @@
+package notifiers
+
+import (
+	"context"
+	"testing"
+
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestBaseNotifier(t *testing.T) {
+	Convey("Base notifier tests", t, func() {
+		Convey("should notify", func() {
+			Convey("pending -> ok", func() {
+				context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
+					State: m.AlertStatePending,
+				})
+				context.Rule.State = m.AlertStateOK
+				So(defaultShouldNotify(context), ShouldBeFalse)
+			})
+
+			Convey("ok -> alerting", func() {
+				context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
+					State: m.AlertStateOK,
+				})
+				context.Rule.State = m.AlertStateAlerting
+				So(defaultShouldNotify(context), ShouldBeTrue)
+			})
+		})
+	})
+}

+ 4 - 0
pkg/services/alerting/notifiers/dingding.go

@@ -38,6 +38,10 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error)
 	}, nil
 }
 
+func (this *DingDingNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 type DingDingNotifier struct {
 	NotifierBase
 	Url string

+ 3 - 5
pkg/services/alerting/notifiers/dingding_test.go

@@ -9,7 +9,7 @@ import (
 )
 
 func TestDingDingNotifier(t *testing.T) {
-	Convey("Line notifier tests", t, func() {
+	Convey("Dingding notifier tests", t, func() {
 		Convey("empty settings should return error", func() {
 			json := `{ }`
 
@@ -25,10 +25,8 @@ func TestDingDingNotifier(t *testing.T) {
 
 		})
 		Convey("settings should trigger incident", func() {
-			json := `
-			{
-  "url": "https://www.google.com"
-			}`
+			json := `{ "url": "https://www.google.com" }`
+
 			settingsJSON, _ := simplejson.NewJson([]byte(json))
 			model := &m.AlertNotification{
 				Name:     "dingding_testing",

+ 4 - 0
pkg/services/alerting/notifiers/email.go

@@ -58,6 +58,10 @@ func NewEmailNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
 	}, nil
 }
 
+func (this *EmailNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *EmailNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Sending alert notification to", "addresses", this.Addresses)
 

+ 4 - 0
pkg/services/alerting/notifiers/hipchat.go

@@ -75,6 +75,10 @@ type HipChatNotifier struct {
 	log    log.Logger
 }
 
+func (this *HipChatNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Executing hipchat notification", "ruleId", evalContext.Rule.Id, "notification", this.Name)
 

+ 4 - 0
pkg/services/alerting/notifiers/kafka.go

@@ -57,6 +57,10 @@ type KafkaNotifier struct {
 	log      log.Logger
 }
 
+func (this *KafkaNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error {
 
 	state := evalContext.Rule.State

+ 4 - 0
pkg/services/alerting/notifiers/line.go

@@ -51,6 +51,10 @@ type LineNotifier struct {
 	log   log.Logger
 }
 
+func (this *LineNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *LineNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Executing line notification", "ruleId", evalContext.Rule.Id, "notification", this.Name)
 

+ 4 - 0
pkg/services/alerting/notifiers/opsgenie.go

@@ -62,6 +62,10 @@ type OpsGenieNotifier struct {
 	log       log.Logger
 }
 
+func (this *OpsGenieNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *OpsGenieNotifier) Notify(evalContext *alerting.EvalContext) error {
 
 	var err error

+ 4 - 0
pkg/services/alerting/notifiers/pagerduty.go

@@ -63,6 +63,10 @@ type PagerdutyNotifier struct {
 	log         log.Logger
 }
 
+func (this *PagerdutyNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
 
 	if evalContext.Rule.State == m.AlertStateOK && !this.AutoResolve {

+ 4 - 0
pkg/services/alerting/notifiers/pushover.go

@@ -123,6 +123,10 @@ type PushoverNotifier struct {
 	log      log.Logger
 }
 
+func (this *PushoverNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error {
 	ruleUrl, err := evalContext.GetRuleUrl()
 	if err != nil {

+ 4 - 0
pkg/services/alerting/notifiers/sensu.go

@@ -71,6 +71,10 @@ type SensuNotifier struct {
 	log      log.Logger
 }
 
+func (this *SensuNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Sending sensu result")
 

+ 4 - 0
pkg/services/alerting/notifiers/slack.go

@@ -98,6 +98,10 @@ type SlackNotifier struct {
 	log       log.Logger
 }
 
+func (this *SlackNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Executing slack notification", "ruleId", evalContext.Rule.Id, "notification", this.Name)
 

+ 0 - 1
pkg/services/alerting/notifiers/slack_test.go

@@ -78,7 +78,6 @@ func TestSlackNotifier(t *testing.T) {
 				So(slackNotifier.Mention, ShouldEqual, "@carl")
 				So(slackNotifier.Token, ShouldEqual, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX")
 			})
-
 		})
 	})
 }

+ 4 - 0
pkg/services/alerting/notifiers/teams.go

@@ -47,6 +47,10 @@ type TeamsNotifier struct {
 	log       log.Logger
 }
 
+func (this *TeamsNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Executing teams notification", "ruleId", evalContext.Rule.Id, "notification", this.Name)
 

+ 4 - 0
pkg/services/alerting/notifiers/telegram.go

@@ -76,6 +76,10 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error)
 	}, nil
 }
 
+func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Sending alert notification to", "bot_token", this.BotToken)
 	this.log.Info("Sending alert notification to", "chat_id", this.ChatID)

+ 4 - 0
pkg/services/alerting/notifiers/threema.go

@@ -114,6 +114,10 @@ func NewThreemaNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
 	}, nil
 }
 
+func (this *ThreemaNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (notifier *ThreemaNotifier) Notify(evalContext *alerting.EvalContext) error {
 	notifier.log.Info("Sending alert notification from", "threema_id", notifier.GatewayID)
 	notifier.log.Info("Sending alert notification to", "threema_id", notifier.RecipientID)

+ 4 - 0
pkg/services/alerting/notifiers/victorops.go

@@ -68,6 +68,10 @@ type VictoropsNotifier struct {
 	log         log.Logger
 }
 
+func (this *VictoropsNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 // Notify sends notification to Victorops via POST to URL endpoint
 func (this *VictoropsNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Executing victorops notification", "ruleId", evalContext.Rule.Id, "notification", this.Name)

+ 4 - 0
pkg/services/alerting/notifiers/webhook.go

@@ -65,6 +65,10 @@ type WebhookNotifier struct {
 	log        log.Logger
 }
 
+func (this *WebhookNotifier) ShouldNotify(context *alerting.EvalContext) bool {
+	return defaultShouldNotify(context)
+}
+
 func (this *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error {
 	this.log.Info("Sending webhook")
 

+ 3 - 3
pkg/services/alerting/notifiers/webhook_test.go

@@ -18,7 +18,7 @@ func TestWebhookNotifier(t *testing.T) {
 				settingsJSON, _ := simplejson.NewJson([]byte(json))
 				model := &m.AlertNotification{
 					Name:     "ops",
-					Type:     "email",
+					Type:     "webhook",
 					Settings: settingsJSON,
 				}
 
@@ -35,7 +35,7 @@ func TestWebhookNotifier(t *testing.T) {
 				settingsJSON, _ := simplejson.NewJson([]byte(json))
 				model := &m.AlertNotification{
 					Name:     "ops",
-					Type:     "email",
+					Type:     "webhook",
 					Settings: settingsJSON,
 				}
 
@@ -44,7 +44,7 @@ func TestWebhookNotifier(t *testing.T) {
 
 				So(err, ShouldBeNil)
 				So(webhookNotifier.Name, ShouldEqual, "ops")
-				So(webhookNotifier.Type, ShouldEqual, "email")
+				So(webhookNotifier.Type, ShouldEqual, "webhook")
 				So(webhookNotifier.Url, ShouldEqual, "http://google.com")
 			})
 		})

+ 2 - 4
pkg/services/alerting/result_handler.go

@@ -85,11 +85,9 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
 		if err := annotationRepo.Save(&item); err != nil {
 			handler.log.Error("Failed to save annotation for new alert state", "error", err)
 		}
-
-		if evalContext.ShouldSendNotification() {
-			handler.notifier.Send(evalContext)
-		}
 	}
 
+	handler.notifier.SendIfNeeded(evalContext)
+
 	return nil
 }

+ 8 - 8
pkg/services/dashboards/dashboards.go

@@ -23,14 +23,12 @@ func SetRepository(rep Repository) {
 }
 
 type SaveDashboardItem struct {
-	TitleLower string
-	OrgId      int64
-	Folder     string
-	UpdatedAt  time.Time
-	UserId     int64
-	Message    string
-	Overwrite  bool
-	Dashboard  *models.Dashboard
+	OrgId     int64
+	UpdatedAt time.Time
+	UserId    int64
+	Message   string
+	Overwrite bool
+	Dashboard *models.Dashboard
 }
 
 type DashboardRepository struct{}
@@ -57,6 +55,8 @@ func (dr *DashboardRepository) SaveDashboard(json *SaveDashboardItem) (*models.D
 		OrgId:     json.OrgId,
 		Overwrite: json.Overwrite,
 		UserId:    json.UserId,
+		FolderId:  dashboard.FolderId,
+		IsFolder:  dashboard.IsFolder,
 	}
 
 	if !json.UpdatedAt.IsZero() {

+ 130 - 0
pkg/services/guardian/guardian.go

@@ -0,0 +1,130 @@
+package guardian
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+type DashboardGuardian struct {
+	user   *m.SignedInUser
+	dashId int64
+	orgId  int64
+	acl    []*m.DashboardAclInfoDTO
+	groups []*m.Team
+	log    log.Logger
+}
+
+func NewDashboardGuardian(dashId int64, orgId int64, user *m.SignedInUser) *DashboardGuardian {
+	return &DashboardGuardian{
+		user:   user,
+		dashId: dashId,
+		orgId:  orgId,
+		log:    log.New("guardians.dashboard"),
+	}
+}
+
+func (g *DashboardGuardian) CanSave() (bool, error) {
+	return g.HasPermission(m.PERMISSION_EDIT)
+}
+
+func (g *DashboardGuardian) CanEdit() (bool, error) {
+	if setting.ViewersCanEdit {
+		return g.HasPermission(m.PERMISSION_VIEW)
+	}
+
+	return g.HasPermission(m.PERMISSION_EDIT)
+}
+
+func (g *DashboardGuardian) CanView() (bool, error) {
+	return g.HasPermission(m.PERMISSION_VIEW)
+}
+
+func (g *DashboardGuardian) CanAdmin() (bool, error) {
+	return g.HasPermission(m.PERMISSION_ADMIN)
+}
+
+func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) {
+	if g.user.OrgRole == m.ROLE_ADMIN {
+		return true, nil
+	}
+
+	acl, err := g.GetAcl()
+	if err != nil {
+		return false, err
+	}
+
+	orgRole := g.user.OrgRole
+	teamAclItems := []*m.DashboardAclInfoDTO{}
+
+	for _, p := range acl {
+		// user match
+		if !g.user.IsAnonymous {
+			if p.UserId == g.user.UserId && p.Permission >= permission {
+				return true, nil
+			}
+		}
+
+		// role match
+		if p.Role != nil {
+			if *p.Role == orgRole && p.Permission >= permission {
+				return true, nil
+			}
+		}
+
+		// remember this rule for later
+		if p.TeamId > 0 {
+			teamAclItems = append(teamAclItems, p)
+		}
+	}
+
+	// do we have group rules?
+	if len(teamAclItems) == 0 {
+		return false, nil
+	}
+
+	// load groups
+	teams, err := g.getTeams()
+	if err != nil {
+		return false, err
+	}
+
+	// evalute group rules
+	for _, p := range acl {
+		for _, ug := range teams {
+			if ug.Id == p.TeamId && p.Permission >= permission {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// Returns dashboard acl
+func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
+	if g.acl != nil {
+		return g.acl, nil
+	}
+
+	query := m.GetDashboardAclInfoListQuery{DashboardId: g.dashId, OrgId: g.orgId}
+	if err := bus.Dispatch(&query); err != nil {
+		return nil, err
+	}
+
+	g.acl = query.Result
+	return g.acl, nil
+}
+
+func (g *DashboardGuardian) getTeams() ([]*m.Team, error) {
+	if g.groups != nil {
+		return g.groups, nil
+	}
+
+	query := m.GetTeamsByUserQuery{UserId: g.user.UserId}
+	err := bus.Dispatch(&query)
+
+	g.groups = query.Result
+	return query.Result, err
+}

+ 3 - 0
pkg/services/provisioning/dashboards/file_reader.go

@@ -121,6 +121,9 @@ func (fr *fileReader) walkFolder() error {
 			return nil
 		}
 
+		// id = 0 indicates ID validation should be avoided before writing to the db.
+		dash.Dashboard.Id = 0
+
 		cmd := &models.GetDashboardQuery{Slug: dash.Dashboard.Slug}
 		err = bus.Dispatch(cmd)
 

+ 0 - 3
pkg/services/provisioning/dashboards/types.go

@@ -1,7 +1,6 @@
 package dashboards
 
 import (
-	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -23,11 +22,9 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das
 
 	dash := &dashboards.SaveDashboardItem{}
 	dash.Dashboard = models.NewDashboardFromJson(data)
-	dash.TitleLower = strings.ToLower(dash.Dashboard.Title)
 	dash.UpdatedAt = lastModified
 	dash.Overwrite = true
 	dash.OrgId = cfg.OrgId
-	dash.Folder = cfg.Folder
 	dash.Dashboard.Data.Set("editable", cfg.Editable)
 
 	if dash.Dashboard.Title == "" {

+ 7 - 35
pkg/services/search/handlers.go

@@ -12,33 +12,24 @@ func Init() {
 }
 
 func searchHandler(query *Query) error {
-	hits := make(HitList, 0)
-
 	dashQuery := FindPersistedDashboardsQuery{
 		Title:        query.Title,
-		UserId:       query.UserId,
+		SignedInUser: query.SignedInUser,
 		IsStarred:    query.IsStarred,
-		OrgId:        query.OrgId,
 		DashboardIds: query.DashboardIds,
+		Type:         query.Type,
+		FolderIds:    query.FolderIds,
+		Tags:         query.Tags,
+		Limit:        query.Limit,
 	}
 
 	if err := bus.Dispatch(&dashQuery); err != nil {
 		return err
 	}
 
+	hits := make(HitList, 0)
 	hits = append(hits, dashQuery.Result...)
 
-	// filter out results with tag filter
-	if len(query.Tags) > 0 {
-		filtered := HitList{}
-		for _, hit := range hits {
-			if hasRequiredTags(query.Tags, hit.Tags) {
-				filtered = append(filtered, hit)
-			}
-		}
-		hits = filtered
-	}
-
 	// sort main result array
 	sort.Sort(hits)
 
@@ -52,7 +43,7 @@ func searchHandler(query *Query) error {
 	}
 
 	// add isStarred info
-	if err := setIsStarredFlagOnSearchResults(query.UserId, hits); err != nil {
+	if err := setIsStarredFlagOnSearchResults(query.SignedInUser.UserId, hits); err != nil {
 		return err
 	}
 
@@ -60,25 +51,6 @@ func searchHandler(query *Query) error {
 	return nil
 }
 
-func stringInSlice(a string, list []string) bool {
-	for _, b := range list {
-		if b == a {
-			return true
-		}
-	}
-	return false
-}
-
-func hasRequiredTags(queryTags, hitTags []string) bool {
-	for _, queryTag := range queryTags {
-		if !stringInSlice(queryTag, hitTags) {
-			return false
-		}
-	}
-
-	return true
-}
-
 func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error {
 	query := m.GetUserStarsQuery{UserId: userId}
 	if err := bus.Dispatch(&query); err != nil {

+ 19 - 22
pkg/services/search/handlers_test.go

@@ -11,13 +11,15 @@ import (
 func TestSearch(t *testing.T) {
 
 	Convey("Given search query", t, func() {
-		query := Query{Limit: 2000}
+		query := Query{Limit: 2000, SignedInUser: &m.SignedInUser{IsGrafanaAdmin: true}}
 
 		bus.AddHandler("test", func(query *FindPersistedDashboardsQuery) error {
 			query.Result = HitList{
-				&Hit{Id: 16, Title: "CCAA", Tags: []string{"BB", "AA"}},
-				&Hit{Id: 10, Title: "AABB", Tags: []string{"CC", "AA"}},
-				&Hit{Id: 15, Title: "BBAA", Tags: []string{"EE", "AA", "BB"}},
+				&Hit{Id: 16, Title: "CCAA", Type: "dash-db", Tags: []string{"BB", "AA"}},
+				&Hit{Id: 10, Title: "AABB", Type: "dash-db", Tags: []string{"CC", "AA"}},
+				&Hit{Id: 15, Title: "BBAA", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}},
+				&Hit{Id: 25, Title: "bbAAa", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}},
+				&Hit{Id: 17, Title: "FOLDER", Type: "dash-folder"},
 			}
 			return nil
 		})
@@ -27,34 +29,29 @@ func TestSearch(t *testing.T) {
 			return nil
 		})
 
+		bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+			query.Result = &m.SignedInUser{IsGrafanaAdmin: true}
+			return nil
+		})
+
 		Convey("That is empty", func() {
 			err := searchHandler(&query)
 			So(err, ShouldBeNil)
 
 			Convey("should return sorted results", func() {
-				So(query.Result[0].Title, ShouldEqual, "AABB")
-				So(query.Result[1].Title, ShouldEqual, "BBAA")
-				So(query.Result[2].Title, ShouldEqual, "CCAA")
+				So(query.Result[0].Title, ShouldEqual, "FOLDER")
+				So(query.Result[1].Title, ShouldEqual, "AABB")
+				So(query.Result[2].Title, ShouldEqual, "BBAA")
+				So(query.Result[3].Title, ShouldEqual, "bbAAa")
+				So(query.Result[4].Title, ShouldEqual, "CCAA")
 			})
 
 			Convey("should return sorted tags", func() {
-				So(query.Result[1].Tags[0], ShouldEqual, "AA")
-				So(query.Result[1].Tags[1], ShouldEqual, "BB")
-				So(query.Result[1].Tags[2], ShouldEqual, "EE")
+				So(query.Result[3].Tags[0], ShouldEqual, "AA")
+				So(query.Result[3].Tags[1], ShouldEqual, "BB")
+				So(query.Result[3].Tags[2], ShouldEqual, "EE")
 			})
 		})
 
-		Convey("That filters by tag", func() {
-			query.Tags = []string{"BB", "AA"}
-			err := searchHandler(&query)
-			So(err, ShouldBeNil)
-
-			Convey("should return correct results", func() {
-				So(len(query.Result), ShouldEqual, 2)
-				So(query.Result[0].Title, ShouldEqual, "BBAA")
-				So(query.Result[1].Title, ShouldEqual, "CCAA")
-			})
-
-		})
 	})
 }

+ 40 - 17
pkg/services/search/models.go

@@ -1,37 +1,55 @@
 package search
 
+import "strings"
+import "github.com/grafana/grafana/pkg/models"
+
 type HitType string
 
 const (
-	DashHitDB       HitType = "dash-db"
-	DashHitHome     HitType = "dash-home"
-	DashHitJson     HitType = "dash-json"
-	DashHitScripted HitType = "dash-scripted"
+	DashHitDB     HitType = "dash-db"
+	DashHitHome   HitType = "dash-home"
+	DashHitFolder HitType = "dash-folder"
 )
 
 type Hit struct {
-	Id        int64    `json:"id"`
-	Title     string   `json:"title"`
-	Uri       string   `json:"uri"`
-	Type      HitType  `json:"type"`
-	Tags      []string `json:"tags"`
-	IsStarred bool     `json:"isStarred"`
+	Id          int64    `json:"id"`
+	Title       string   `json:"title"`
+	Uri         string   `json:"uri"`
+	Slug        string   `json:"slug"`
+	Type        HitType  `json:"type"`
+	Tags        []string `json:"tags"`
+	IsStarred   bool     `json:"isStarred"`
+	FolderId    int64    `json:"folderId,omitempty"`
+	FolderTitle string   `json:"folderTitle,omitempty"`
+	FolderSlug  string   `json:"folderSlug,omitempty"`
 }
 
 type HitList []*Hit
 
-func (s HitList) Len() int           { return len(s) }
-func (s HitList) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
-func (s HitList) Less(i, j int) bool { return s[i].Title < s[j].Title }
+func (s HitList) Len() int      { return len(s) }
+func (s HitList) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
+func (s HitList) Less(i, j int) bool {
+	if s[i].Type == "dash-folder" && s[j].Type == "dash-db" {
+		return true
+	}
+
+	if s[i].Type == "dash-db" && s[j].Type == "dash-folder" {
+		return false
+	}
+
+	return strings.ToLower(s[i].Title) < strings.ToLower(s[j].Title)
+}
 
 type Query struct {
 	Title        string
 	Tags         []string
 	OrgId        int64
-	UserId       int64
+	SignedInUser *models.SignedInUser
 	Limit        int
 	IsStarred    bool
-	DashboardIds []int
+	Type         string
+	DashboardIds []int64
+	FolderIds    []int64
 
 	Result HitList
 }
@@ -39,9 +57,14 @@ type Query struct {
 type FindPersistedDashboardsQuery struct {
 	Title        string
 	OrgId        int64
-	UserId       int64
+	SignedInUser *models.SignedInUser
 	IsStarred    bool
-	DashboardIds []int
+	DashboardIds []int64
+	Type         string
+	FolderIds    []int64
+	Tags         []string
+	Limit        int
+	IsBrowse     bool
 
 	Result HitList
 }

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

@@ -12,7 +12,7 @@ func TestAlertingDataAccess(t *testing.T) {
 	Convey("Testing Alerting data access", t, func() {
 		InitTestDB(t)
 
-		testDash := insertTestDashboard("dashboard with alerts", 1, "alert")
+		testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
 
 		items := []*m.Alert{
 			{
@@ -192,7 +192,7 @@ func TestAlertingDataAccess(t *testing.T) {
 
 			err = DeleteDashboard(&m.DeleteDashboardCommand{
 				OrgId: 1,
-				Slug:  testDash.Slug,
+				Id:    testDash.Id,
 			})
 
 			So(err, ShouldBeNil)

+ 96 - 54
pkg/services/sqlstore/dashboard.go

@@ -1,8 +1,6 @@
 package sqlstore
 
 import (
-	"bytes"
-	"fmt"
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -70,6 +68,11 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			}
 		}
 
+		err = setHasAcl(sess, dash)
+		if err != nil {
+			return err
+		}
+
 		parentVersion := dash.Version
 		affectedRows := int64(0)
 
@@ -79,14 +82,14 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			dash.Data.Set("version", dash.Version)
 			affectedRows, err = sess.Insert(dash)
 		} else {
-			dash.Version += 1
+			dash.Version++
 			dash.Data.Set("version", dash.Version)
 
 			if !cmd.UpdatedAt.IsZero() {
 				dash.Updated = cmd.UpdatedAt
 			}
 
-			affectedRows, err = sess.Id(dash.Id).Update(dash)
+			affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
 		}
 
 		if err != nil {
@@ -115,7 +118,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			return m.ErrDashboardNotFound
 		}
 
-		// delete existing tabs
+		// delete existing tags
 		_, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id)
 		if err != nil {
 			return err
@@ -130,13 +133,37 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 				}
 			}
 		}
-
 		cmd.Result = dash
 
 		return err
 	})
 }
 
+func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
+	// check if parent has acl
+	if dash.FolderId > 0 {
+		var parent m.Dashboard
+		if hasParent, err := sess.Where("folder_id=?", dash.FolderId).Get(&parent); err != nil {
+			return err
+		} else if hasParent && parent.HasAcl {
+			dash.HasAcl = true
+		}
+	}
+
+	// check if dash has its own acl
+	if dash.Id > 0 {
+		if res, err := sess.Query("SELECT 1 from dashboard_acl WHERE dashboard_id =?", dash.Id); err != nil {
+			return err
+		} else {
+			if len(res) > 0 {
+				dash.HasAcl = true
+			}
+		}
+	}
+
+	return nil
+}
+
 func GetDashboard(query *m.GetDashboardQuery) error {
 	dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id}
 	has, err := x.Get(&dashboard)
@@ -153,64 +180,76 @@ func GetDashboard(query *m.GetDashboardQuery) error {
 }
 
 type DashboardSearchProjection struct {
-	Id    int64
-	Title string
-	Slug  string
-	Term  string
+	Id          int64
+	Title       string
+	Slug        string
+	Term        string
+	IsFolder    bool
+	FolderId    int64
+	FolderSlug  string
+	FolderTitle string
 }
 
-func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
-	var sql bytes.Buffer
-	params := make([]interface{}, 0)
-
-	sql.WriteString(`SELECT
-					  dashboard.id,
-					  dashboard.title,
-					  dashboard.slug,
-					  dashboard_tag.term
-					FROM dashboard
-					LEFT OUTER JOIN dashboard_tag on dashboard_tag.dashboard_id = dashboard.id`)
-
-	if query.IsStarred {
-		sql.WriteString(" INNER JOIN star on star.dashboard_id = dashboard.id")
+func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) {
+	limit := query.Limit
+	if limit == 0 {
+		limit = 1000
 	}
 
-	sql.WriteString(` WHERE dashboard.org_id=?`)
-
-	params = append(params, query.OrgId)
+	sb := NewSearchBuilder(query.SignedInUser, limit).
+		WithTags(query.Tags).
+		WithDashboardIdsIn(query.DashboardIds)
 
 	if query.IsStarred {
-		sql.WriteString(` AND star.user_id=?`)
-		params = append(params, query.UserId)
+		sb.IsStarred()
 	}
 
-	if len(query.DashboardIds) > 0 {
-		sql.WriteString(" AND (")
-		for i, dashboardId := range query.DashboardIds {
-			if i != 0 {
-				sql.WriteString(" OR")
-			}
-
-			sql.WriteString(" dashboard.id = ?")
-			params = append(params, dashboardId)
-		}
-		sql.WriteString(")")
+	if len(query.Title) > 0 {
+		sb.WithTitle(query.Title)
 	}
 
-	if len(query.Title) > 0 {
-		sql.WriteString(" AND dashboard.title " + dialect.LikeStr() + " ?")
-		params = append(params, "%"+query.Title+"%")
+	if len(query.Type) > 0 {
+		sb.WithType(query.Type)
 	}
 
-	sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000"))
+	if len(query.FolderIds) > 0 {
+		sb.WithFolderIds(query.FolderIds)
+	}
 
 	var res []DashboardSearchProjection
 
-	err := x.Sql(sql.String(), params...).Find(&res)
+	sql, params := sb.ToSql()
+	err := x.Sql(sql, params...).Find(&res)
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
+	res, err := findDashboards(query)
 	if err != nil {
 		return err
 	}
 
+	makeQueryResult(query, res)
+
+	return nil
+}
+
+func getHitType(item DashboardSearchProjection) search.HitType {
+	var hitType search.HitType
+	if item.IsFolder {
+		hitType = search.DashHitFolder
+	} else {
+		hitType = search.DashHitDB
+	}
+
+	return hitType
+}
+
+func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []DashboardSearchProjection) {
 	query.Result = make([]*search.Hit, 0)
 	hits := make(map[int64]*search.Hit)
 
@@ -218,11 +257,15 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
 		hit, exists := hits[item.Id]
 		if !exists {
 			hit = &search.Hit{
-				Id:    item.Id,
-				Title: item.Title,
-				Uri:   "db/" + item.Slug,
-				Type:  search.DashHitDB,
-				Tags:  []string{},
+				Id:          item.Id,
+				Title:       item.Title,
+				Uri:         "db/" + item.Slug,
+				Slug:        item.Slug,
+				Type:        getHitType(item),
+				FolderId:    item.FolderId,
+				FolderTitle: item.FolderTitle,
+				FolderSlug:  item.FolderSlug,
+				Tags:        []string{},
 			}
 			query.Result = append(query.Result, hit)
 			hits[item.Id] = hit
@@ -231,8 +274,6 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
 			hit.Tags = append(hit.Tags, item.Term)
 		}
 	}
-
-	return err
 }
 
 func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
@@ -252,7 +293,7 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
 
 func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		dashboard := m.Dashboard{Slug: cmd.Slug, OrgId: cmd.OrgId}
+		dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId}
 		has, err := sess.Get(&dashboard)
 		if err != nil {
 			return err
@@ -266,6 +307,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 			"DELETE FROM dashboard WHERE id = ?",
 			"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
 			"DELETE FROM dashboard_version WHERE dashboard_id = ?",
+			"DELETE FROM dashboard WHERE folder_id = ?",
 			"DELETE FROM annotation WHERE dashboard_id = ?",
 		}
 
@@ -304,7 +346,7 @@ func GetDashboards(query *m.GetDashboardsQuery) error {
 func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error {
 	var dashboards = make([]*m.Dashboard, 0)
 
-	err := x.Where("org_id=? AND plugin_id=?", query.OrgId, query.PluginId).Find(&dashboards)
+	err := x.Where("org_id=? AND plugin_id=? AND is_folder=0", query.OrgId, query.PluginId).Find(&dashboards)
 	query.Result = dashboards
 
 	if err != nil {

+ 184 - 0
pkg/services/sqlstore/dashboard_acl.go

@@ -0,0 +1,184 @@
+package sqlstore
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func init() {
+	bus.AddHandler("sql", SetDashboardAcl)
+	bus.AddHandler("sql", UpdateDashboardAcl)
+	bus.AddHandler("sql", RemoveDashboardAcl)
+	bus.AddHandler("sql", GetDashboardAclInfoList)
+}
+
+func UpdateDashboardAcl(cmd *m.UpdateDashboardAclCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		// delete existing items
+		_, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", cmd.DashboardId)
+		if err != nil {
+			return err
+		}
+
+		for _, item := range cmd.Items {
+			if item.UserId == 0 && item.TeamId == 0 && !item.Role.IsValid() {
+				return m.ErrDashboardAclInfoMissing
+			}
+
+			if item.DashboardId == 0 {
+				return m.ErrDashboardPermissionDashboardEmpty
+			}
+
+			sess.Nullable("user_id", "team_id")
+			if _, err := sess.Insert(item); err != nil {
+				return err
+			}
+		}
+
+		// Update dashboard HasAcl flag
+		dashboard := m.Dashboard{HasAcl: true}
+		if _, err := sess.Cols("has_acl").Where("id=? OR folder_id=?", cmd.DashboardId, cmd.DashboardId).Update(&dashboard); err != nil {
+			return err
+		}
+		return nil
+	})
+}
+
+func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		if cmd.UserId == 0 && cmd.TeamId == 0 {
+			return m.ErrDashboardAclInfoMissing
+		}
+
+		if cmd.DashboardId == 0 {
+			return m.ErrDashboardPermissionDashboardEmpty
+		}
+
+		if res, err := sess.Query("SELECT 1 from "+dialect.Quote("dashboard_acl")+" WHERE dashboard_id =? and (team_id=? or user_id=?)", cmd.DashboardId, cmd.TeamId, cmd.UserId); err != nil {
+			return err
+		} else if len(res) == 1 {
+
+			entity := m.DashboardAcl{
+				Permission: cmd.Permission,
+				Updated:    time.Now(),
+			}
+
+			if _, err := sess.Cols("updated", "permission").Where("dashboard_id =? and (team_id=? or user_id=?)", cmd.DashboardId, cmd.TeamId, cmd.UserId).Update(&entity); err != nil {
+				return err
+			}
+
+			return nil
+		}
+
+		entity := m.DashboardAcl{
+			OrgId:       cmd.OrgId,
+			TeamId:      cmd.TeamId,
+			UserId:      cmd.UserId,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+			DashboardId: cmd.DashboardId,
+			Permission:  cmd.Permission,
+		}
+
+		cols := []string{"org_id", "created", "updated", "dashboard_id", "permission"}
+
+		if cmd.UserId != 0 {
+			cols = append(cols, "user_id")
+		}
+
+		if cmd.TeamId != 0 {
+			cols = append(cols, "team_id")
+		}
+
+		_, err := sess.Cols(cols...).Insert(&entity)
+		if err != nil {
+			return err
+		}
+
+		cmd.Result = entity
+
+		// Update dashboard HasAcl flag
+		dashboard := m.Dashboard{
+			HasAcl: true,
+		}
+
+		if _, err := sess.Cols("has_acl").Where("id=? OR folder_id=?", cmd.DashboardId, cmd.DashboardId).Update(&dashboard); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
+
+func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?"
+		_, err := sess.Exec(rawSQL, cmd.OrgId, cmd.AclId)
+		if err != nil {
+			return err
+		}
+
+		return err
+	})
+}
+
+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
+		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 = 0 or folder.has_acl = 0) AND da.dashboard_id = -1
+	`
+
+	query.Result = make([]*m.DashboardAclInfoDTO, 0)
+	err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result)
+
+	for _, p := range query.Result {
+		p.PermissionName = p.Permission.String()
+	}
+
+	return err
+}

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

@@ -0,0 +1,236 @@
+package sqlstore
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func TestDashboardAclDataAccess(t *testing.T) {
+	Convey("Testing DB", t, func() {
+		InitTestDB(t)
+		Convey("Given a dashboard folder and a user", func() {
+			currentUser := createUser("viewer", "Viewer", false)
+			savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
+			childDash := insertTestDashboard("2 test dash", 1, savedFolder.Id, false, "prod", "webapp")
+
+			Convey("When adding dashboard permission with userId and teamId set to 0", func() {
+				err := SetDashboardAcl(&m.SetDashboardAclCommand{
+					OrgId:       1,
+					DashboardId: savedFolder.Id,
+					Permission:  m.PERMISSION_EDIT,
+				})
+				So(err, ShouldEqual, m.ErrDashboardAclInfoMissing)
+			})
+
+			Convey("Given dashboard folder with default permissions", func() {
+				Convey("When reading dashboard acl should include acl for parent folder", func() {
+					query := m.GetDashboardAclInfoListQuery{DashboardId: childDash.Id, 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)
+				})
+			})
+
+			Convey("Given dashboard folder permission", func() {
+				err := SetDashboardAcl(&m.SetDashboardAclCommand{
+					OrgId:       1,
+					UserId:      currentUser.Id,
+					DashboardId: savedFolder.Id,
+					Permission:  m.PERMISSION_EDIT,
+				})
+				So(err, ShouldBeNil)
+
+				Convey("When reading dashboard acl should include acl for parent folder", func() {
+					query := m.GetDashboardAclInfoListQuery{DashboardId: childDash.Id, OrgId: 1}
+
+					err := GetDashboardAclInfoList(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
+				})
+
+				Convey("Given child dashboard permission", func() {
+					err := SetDashboardAcl(&m.SetDashboardAclCommand{
+						OrgId:       1,
+						UserId:      currentUser.Id,
+						DashboardId: childDash.Id,
+						Permission:  m.PERMISSION_EDIT,
+					})
+					So(err, ShouldBeNil)
+
+					Convey("When reading dashboard acl should include acl for parent folder and child", func() {
+						query := m.GetDashboardAclInfoListQuery{OrgId: 1, DashboardId: childDash.Id}
+
+						err := GetDashboardAclInfoList(&query)
+						So(err, ShouldBeNil)
+
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
+						So(query.Result[1].DashboardId, ShouldEqual, childDash.Id)
+					})
+				})
+			})
+
+			Convey("Given child dashboard permission in folder with no permissions", func() {
+				err := SetDashboardAcl(&m.SetDashboardAclCommand{
+					OrgId:       1,
+					UserId:      currentUser.Id,
+					DashboardId: childDash.Id,
+					Permission:  m.PERMISSION_EDIT,
+				})
+				So(err, ShouldBeNil)
+
+				Convey("When reading dashboard acl should include default acl for parent folder and the child acl", func() {
+					query := m.GetDashboardAclInfoListQuery{OrgId: 1, DashboardId: childDash.Id}
+
+					err := GetDashboardAclInfoList(&query)
+					So(err, ShouldBeNil)
+
+					defaultPermissionsId := -1
+					So(len(query.Result), ShouldEqual, 3)
+					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)
+					So(query.Result[2].DashboardId, ShouldEqual, childDash.Id)
+				})
+			})
+
+			Convey("Should be able to add dashboard permission", func() {
+				setDashAclCmd := m.SetDashboardAclCommand{
+					OrgId:       1,
+					UserId:      currentUser.Id,
+					DashboardId: savedFolder.Id,
+					Permission:  m.PERMISSION_EDIT,
+				}
+
+				err := SetDashboardAcl(&setDashAclCmd)
+				So(err, ShouldBeNil)
+
+				So(setDashAclCmd.Result.Id, ShouldEqual, 3)
+
+				q1 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1}
+				err = GetDashboardAclInfoList(q1)
+				So(err, ShouldBeNil)
+
+				So(q1.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
+				So(q1.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
+				So(q1.Result[0].PermissionName, ShouldEqual, "Edit")
+				So(q1.Result[0].UserId, ShouldEqual, currentUser.Id)
+				So(q1.Result[0].UserLogin, ShouldEqual, currentUser.Login)
+				So(q1.Result[0].UserEmail, ShouldEqual, currentUser.Email)
+				So(q1.Result[0].Id, ShouldEqual, setDashAclCmd.Result.Id)
+
+				Convey("Should update hasAcl field to true for dashboard folder and its children", func() {
+					q2 := &m.GetDashboardsQuery{DashboardIds: []int64{savedFolder.Id, childDash.Id}}
+					err := GetDashboards(q2)
+					So(err, ShouldBeNil)
+					So(q2.Result[0].HasAcl, ShouldBeTrue)
+					So(q2.Result[1].HasAcl, ShouldBeTrue)
+				})
+
+				Convey("Should be able to update an existing permission", func() {
+					err := SetDashboardAcl(&m.SetDashboardAclCommand{
+						OrgId:       1,
+						UserId:      1,
+						DashboardId: savedFolder.Id,
+						Permission:  m.PERMISSION_ADMIN,
+					})
+
+					So(err, ShouldBeNil)
+
+					q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1}
+					err = GetDashboardAclInfoList(q3)
+					So(err, ShouldBeNil)
+					So(len(q3.Result), ShouldEqual, 1)
+					So(q3.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
+					So(q3.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+					So(q3.Result[0].UserId, ShouldEqual, 1)
+
+				})
+
+				Convey("Should be able to delete an existing permission", func() {
+					err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{
+						OrgId: 1,
+						AclId: setDashAclCmd.Result.Id,
+					})
+
+					So(err, ShouldBeNil)
+
+					q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1}
+					err = GetDashboardAclInfoList(q3)
+					So(err, ShouldBeNil)
+					So(len(q3.Result), ShouldEqual, 0)
+				})
+			})
+
+			Convey("Given a team", func() {
+				group1 := m.CreateTeamCommand{Name: "group1 name", OrgId: 1}
+				err := CreateTeam(&group1)
+				So(err, ShouldBeNil)
+
+				Convey("Should be able to add a user permission for a team", func() {
+					setDashAclCmd := m.SetDashboardAclCommand{
+						OrgId:       1,
+						TeamId:      group1.Result.Id,
+						DashboardId: savedFolder.Id,
+						Permission:  m.PERMISSION_EDIT,
+					}
+
+					err := SetDashboardAcl(&setDashAclCmd)
+					So(err, ShouldBeNil)
+
+					q1 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1}
+					err = GetDashboardAclInfoList(q1)
+					So(err, ShouldBeNil)
+					So(q1.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
+					So(q1.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
+					So(q1.Result[0].TeamId, ShouldEqual, group1.Result.Id)
+
+					Convey("Should be able to delete an existing permission for a team", func() {
+						err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{
+							OrgId: 1,
+							AclId: setDashAclCmd.Result.Id,
+						})
+
+						So(err, ShouldBeNil)
+						q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1}
+						err = GetDashboardAclInfoList(q3)
+						So(err, ShouldBeNil)
+						So(len(q3.Result), ShouldEqual, 0)
+					})
+				})
+
+				Convey("Should be able to update an existing permission for a team", func() {
+					err := SetDashboardAcl(&m.SetDashboardAclCommand{
+						OrgId:       1,
+						TeamId:      group1.Result.Id,
+						DashboardId: savedFolder.Id,
+						Permission:  m.PERMISSION_ADMIN,
+					})
+					So(err, ShouldBeNil)
+
+					q3 := &m.GetDashboardAclInfoListQuery{DashboardId: savedFolder.Id, OrgId: 1}
+					err = GetDashboardAclInfoList(q3)
+					So(err, ShouldBeNil)
+					So(len(q3.Result), ShouldEqual, 1)
+					So(q3.Result[0].DashboardId, ShouldEqual, savedFolder.Id)
+					So(q3.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+					So(q3.Result[0].TeamId, ShouldEqual, group1.Result.Id)
+				})
+
+			})
+		})
+	})
+}

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