Browse Source

Merge branch 'develop' into panel-title-menu-ux

Torkel Ödegaard 8 years ago
parent
commit
9fb60c2fc8
100 changed files with 4963 additions and 845 deletions
  1. 26 8
      pkg/api/api.go
  2. 120 84
      pkg/api/dashboard.go
  3. 81 0
      pkg/api/dashboard_acl.go
  4. 174 0
      pkg/api/dashboard_acl_test.go
  5. 507 0
      pkg/api/dashboard_test.go
  6. 11 2
      pkg/api/datasources_test.go
  7. 16 0
      pkg/api/dtos/acl.go
  8. 19 14
      pkg/api/dtos/dashboard.go
  9. 26 11
      pkg/api/index.go
  10. 1 1
      pkg/api/playlist.go
  11. 9 9
      pkg/api/playlist_play.go
  12. 3 1
      pkg/api/render.go
  13. 7 3
      pkg/api/search.go
  14. 1 1
      pkg/api/user.go
  15. 95 0
      pkg/api/user_group.go
  16. 44 0
      pkg/api/user_group_members.go
  17. 71 0
      pkg/api/user_group_test.go
  18. 15 7
      pkg/components/renderer/renderer.go
  19. 5 0
      pkg/metrics/metrics.go
  20. 3 2
      pkg/middleware/render_auth.go
  21. 95 0
      pkg/models/dashboard_acl.go
  22. 21 0
      pkg/models/dashboard_acl_test.go
  23. 14 6
      pkg/models/dashboards.go
  24. 23 0
      pkg/models/dashboards_test.go
  25. 12 3
      pkg/models/org_user.go
  26. 8 0
      pkg/models/user.go
  27. 68 0
      pkg/models/user_group.go
  28. 55 0
      pkg/models/user_group_member.go
  29. 5 2
      pkg/plugins/dashboards.go
  30. 2 2
      pkg/plugins/dashboards_updater.go
  31. 5 4
      pkg/services/alerting/notifier.go
  32. 127 0
      pkg/services/guardian/guardian.go
  33. 7 75
      pkg/services/search/handlers.go
  34. 19 24
      pkg/services/search/handlers_test.go
  35. 0 137
      pkg/services/search/json_index.go
  36. 0 42
      pkg/services/search/json_index_test.go
  37. 38 17
      pkg/services/search/models.go
  38. 2 2
      pkg/services/sqlstore/alert_test.go
  39. 152 35
      pkg/services/sqlstore/dashboard.go
  40. 184 0
      pkg/services/sqlstore/dashboard_acl.go
  41. 236 0
      pkg/services/sqlstore/dashboard_acl_test.go
  42. 350 34
      pkg/services/sqlstore/dashboard_test.go
  43. 4 0
      pkg/services/sqlstore/dashboard_version.go
  44. 2 2
      pkg/services/sqlstore/dashboard_version_test.go
  45. 1 0
      pkg/services/sqlstore/datasource_test.go
  46. 52 0
      pkg/services/sqlstore/migrations/dashboard_acl.go
  47. 14 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  48. 2 0
      pkg/services/sqlstore/migrations/migrations.go
  49. 48 0
      pkg/services/sqlstore/migrations/user_group_mig.go
  50. 51 0
      pkg/services/sqlstore/org_test.go
  51. 11 4
      pkg/services/sqlstore/org_users.go
  52. 4 0
      pkg/services/sqlstore/user.go
  53. 233 0
      pkg/services/sqlstore/user_group.go
  54. 114 0
      pkg/services/sqlstore/user_group_test.go
  55. 112 58
      pkg/services/sqlstore/user_test.go
  56. 0 8
      public/app/core/components/grafana_app.ts
  57. 2 9
      public/app/core/components/navbar/navbar.html
  58. 14 32
      public/app/core/components/search/search.html
  59. 42 8
      public/app/core/components/search/search.ts
  60. 41 41
      public/app/core/components/sidemenu/sidemenu.html
  61. 9 1
      public/app/core/components/sidemenu/sidemenu.ts
  62. 60 0
      public/app/core/components/user_group_picker.ts
  63. 67 0
      public/app/core/components/user_picker.ts
  64. 4 1
      public/app/core/core.ts
  65. 21 8
      public/app/core/directives/dash_edit_link.js
  66. 11 2
      public/app/core/nav_model_srv.ts
  67. 12 0
      public/app/core/routes/routes.ts
  68. 54 0
      public/app/core/services/backend_srv.ts
  69. 1 3
      public/app/core/services/context_srv.ts
  70. 0 5
      public/app/features/alerting/partials/alert_list.html
  71. 126 0
      public/app/features/dashboard/acl/acl.html
  72. 204 0
      public/app/features/dashboard/acl/acl.ts
  73. 180 0
      public/app/features/dashboard/acl/specs/acl_specs.ts
  74. 3 0
      public/app/features/dashboard/all.js
  75. 6 0
      public/app/features/dashboard/dashboard_ctrl.ts
  76. 0 7
      public/app/features/dashboard/dashboard_srv.ts
  77. 52 83
      public/app/features/dashboard/dashnav/dashnav.html
  78. 3 10
      public/app/features/dashboard/dashnav/dashnav.ts
  79. 24 0
      public/app/features/dashboard/folder_modal/folder.html
  80. 45 0
      public/app/features/dashboard/folder_modal/folder.ts
  81. 83 0
      public/app/features/dashboard/folder_picker/picker.ts
  82. 0 1
      public/app/features/dashboard/import/dash_import.html
  83. 2 0
      public/app/features/dashboard/model.ts
  84. 15 31
      public/app/features/dashboard/partials/settings.html
  85. 13 1
      public/app/features/dashboard/save_as_modal.ts
  86. 3 1
      public/app/features/org/all.js
  87. 38 0
      public/app/features/org/create_user_group_modal.ts
  88. 26 0
      public/app/features/org/partials/create_user_group.html
  89. 49 0
      public/app/features/org/partials/user_group_details.html
  90. 61 0
      public/app/features/org/partials/user_groups.html
  91. 45 0
      public/app/features/org/specs/user_group_details_ctrl_specs.ts
  92. 78 0
      public/app/features/org/user_group_details_ctrl.ts
  93. 68 0
      public/app/features/org/user_groups_ctrl.ts
  94. 3 3
      public/app/features/panel/panel_header.ts
  95. 8 0
      public/app/plugins/panel/dashlist/editor.html
  96. 7 0
      public/app/plugins/panel/dashlist/module.ts
  97. 13 0
      public/app/plugins/panel/permissionlist/editor.html
  98. 75 0
      public/app/plugins/panel/permissionlist/img/icn-singlestat-panel.svg
  99. 30 0
      public/app/plugins/panel/permissionlist/module.html
  100. 60 0
      public/app/plugins/panel/permissionlist/module.ts

+ 26 - 8
pkg/api/api.go

@@ -132,6 +132,18 @@ func (hs *HttpServer) registerRoutes() {
 			r.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg))
 		}, reqGrafanaAdmin)
 
+		// user group (admin permission required)
+		r.Group("/user-groups", func() {
+			r.Get("/:userGroupId", wrap(GetUserGroupById))
+			r.Get("/search", wrap(SearchUserGroups))
+			r.Post("/", quota("user-groups"), bind(m.CreateUserGroupCommand{}), wrap(CreateUserGroup))
+			r.Put("/:userGroupId", bind(m.UpdateUserGroupCommand{}), wrap(UpdateUserGroup))
+			r.Delete("/:userGroupId", wrap(DeleteUserGroupById))
+			r.Get("/:userGroupId/members", wrap(GetUserGroupMembers))
+			r.Post("/:userGroupId/members", quota("user-groups"), bind(m.AddUserGroupMemberCommand{}), wrap(AddUserGroupMember))
+			r.Delete("/:userGroupId/members/:userId", wrap(RemoveUserGroupMember))
+		}, reqOrgAdmin)
+
 		// org information available to all users.
 		r.Group("/org", func() {
 			r.Get("/", wrap(GetOrgCurrent))
@@ -222,19 +234,25 @@ func (hs *HttpServer) registerRoutes() {
 
 		// Dashboard
 		r.Group("/dashboards", func() {
-			r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard)
-
-			r.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions))
-			r.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion))
-			r.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
+			r.Combo("/db/:slug").Get(wrap(GetDashboard)).Delete(wrap(DeleteDashboard))
+			r.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
 
 			r.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
-
-			r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
-			r.Get("/file/:file", GetDashboardFromJsonFile)
 			r.Get("/home", wrap(GetHomeDashboard))
 			r.Get("/tags", GetDashboardTags)
 			r.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
+
+			r.Group("/id/:dashboardId", func() {
+				r.Get("/versions", wrap(GetDashboardVersions))
+				r.Get("/versions/:id", wrap(GetDashboardVersion))
+				r.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
+
+				r.Group("/acl", func() {
+					r.Get("/", wrap(GetDashboardAclList))
+					r.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl))
+					r.Delete("/:aclId", wrap(DeleteDashboardAcl))
+				})
+			}, reqSignedIn)
 		})
 
 		// Dashboard snapshots

+ 120 - 84
pkg/api/dashboard.go

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"os"
 	"path"
-	"strings"
 
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
@@ -17,7 +16,7 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/services/alerting"
-	"github.com/grafana/grafana/pkg/services/search"
+	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
@@ -35,23 +34,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)
 	}
+}
 
-	isStarred, err := isDashboardStarredByUser(c, query.Result.Id)
-	if err != nil {
-		c.JsonApiErr(500, "Error while checking if dashboard was starred by user", err)
-		return
+func GetDashboard(c *middleware.Context) Response {
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	if rsp != nil {
+		return rsp
+	}
+
+	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 +72,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 +123,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
+}
 
-	cmd := m.DeleteDashboardCommand{Slug: slug, OrgId: c.OrgId}
-	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to delete dashboard", err)
-		return
+func DeleteDashboard(c *middleware.Context) Response {
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	if rsp != nil {
+		return rsp
+	}
+
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
 	}
 
-	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 +157,22 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 
 	dash := cmd.GetDashboardModel()
 
+	// look up existing dashboard
+	if dash.Id > 0 {
+		if existing, _ := getDashboardHelper(c.OrgId, "", dash.Id); existing != nil {
+			dash.HasAcl = existing.HasAcl
+		}
+	}
+
+	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)
@@ -182,11 +231,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": cmd.Result.Slug, "version": cmd.Result.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": cmd.Result.Slug, "version": cmd.Result.Version, "id": cmd.Result.Id})
 }
 
 func GetHomeDashboard(c *middleware.Context) Response {
@@ -214,7 +259,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_READ_ONLY_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)
@@ -242,41 +289,24 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
 	row.Set("panels", panels)
 }
 
-func GetDashboardFromJsonFile(c *middleware.Context) {
-	file := c.Params(":file")
-
-	dashboard := search.GetDashboardFromJsonIndex(file)
-	if dashboard == nil {
-		c.JsonApiErr(404, "Dashboard not found", nil)
-		return
-	}
-
-	dash := dtos.DashboardFullWithMeta{Dashboard: dashboard.Data}
-	dash.Meta.Type = m.DashTypeJson
-	dash.Meta.CanEdit = canEditDashboard(c.OrgRole)
-
-	c.JSON(200, &dash)
-}
-
 // 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 {
@@ -300,17 +330,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"
@@ -361,19 +395,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{}
@@ -381,7 +417,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)

+ 81 - 0
pkg/api/dashboard_acl.go

@@ -0,0 +1,81 @@
+package api
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/metrics"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
+)
+
+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,
+			UserGroupId: item.UserGroupId,
+			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)
+	}
+
+	metrics.M_Api_Dashboard_Acl_Update.Inc(1)
+	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, UserGroupId: 1, Permission: m.PERMISSION_VIEW},
+			{Id: 5, OrgId: 1, DashboardId: 1, UserGroupId: 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
+		})
+
+		userGroupResp := []*m.UserGroup{}
+		bus.AddHandler("test", func(query *m.GetUserGroupsByUserQuery) error {
+			query.Result = userGroupResp
+			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 user group 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) {
+					userGroupResp = append(userGroupResp, &m.UserGroup{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,
+			UserGroupId: acl.UserGroupId,
+		}
+		dtos = append(dtos, dto)
+	}
+
+	return dtos
+}

+ 507 - 0
pkg/api/dashboard_test.go

@@ -0,0 +1,507 @@
+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/smartystreets/goconvey/convey"
+)
+
+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.GetUserGroupsByUserQuery) error {
+			query.Result = []*m.UserGroup{}
+			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 Read Only Editor", func() {
+			role := m.ROLE_READ_ONLY_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 view but not save the 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
+
+		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.GetUserGroupsByUserQuery) error {
+			query.Result = []*m.UserGroup{}
+			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 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)
+		})
+
+		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"`
+	UserGroupId int64            `json:"userGroupId"`
+	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 {

+ 26 - 11
pkg/api/index.go

@@ -84,16 +84,23 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 		data.User.LightTheme = true
 	}
 
-	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.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
+			Text: "New",
+			Icon: "fa fa-fw fa-plus",
+			Url:  "",
+			Children: []*dtos.NavLink{
+				{Text: "Dashboard", Icon: "fa fa-fw fa-plus", Url: setting.AppSubUrl + "/dashboard/new"},
+				{Text: "Folder", Icon: "fa fa-fw fa-plus", Url: setting.AppSubUrl + "/dashboard/new/?editview=new-folder"},
+				{Text: "Import", Icon: "fa fa-fw fa-plus", Url: setting.AppSubUrl + "/dashboard/new/?editview=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: "fa fa-fw fa-home"},
+		{Text: "Playlists", Url: setting.AppSubUrl + "/playlists", Icon: "fa fa-fw fa-film"},
+		{Text: "Snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "icon-gf icon-gf-snapshot"},
 	}
 
 	data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
@@ -105,8 +112,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 
 	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 List", Url: setting.AppSubUrl + "/alerting/list", Icon: "fa fa-fw fa-list-ul"},
+			{Text: "Notification channels", Url: setting.AppSubUrl + "/alerting/notifications", Icon: "fa fa-fw fa-bell-o"},
 		}
 
 		data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
@@ -122,12 +129,20 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 			Text: "Data Sources",
 			Icon: "icon-gf icon-gf-datasources",
 			Url:  setting.AppSubUrl + "/datasources",
+			Children: []*dtos.NavLink{
+				{Text: "List", Url: setting.AppSubUrl + "/datasources", Icon: "icon-gf icon-gf-datasources"},
+				{Text: "New", Url: setting.AppSubUrl + "/datasources", Icon: "fa fa-fw fa-plus"},
+			},
 		})
-
 		data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
 			Text: "Plugins",
 			Icon: "icon-gf icon-gf-apps",
 			Url:  setting.AppSubUrl + "/plugins",
+			Children: []*dtos.NavLink{
+				{Text: "Panels", Url: setting.AppSubUrl + "/plugins?type=panel", Icon: "fa fa-fw fa-stop"},
+				{Text: "Data sources", Url: setting.AppSubUrl + "/plugins?type=datasource", Icon: "icon-gf icon-gf-datasources"},
+				{Text: "Apps", Url: setting.AppSubUrl + "/plugins?type=app", Icon: "icon-gf icon-gf-apps"},
+			},
 		})
 	}
 

+ 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

+ 3 - 1
pkg/api/render.go

@@ -17,8 +17,10 @@ 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", ""),
 	}
 

+ 7 - 3
pkg/api/search.go

@@ -14,14 +14,16 @@ func Search(c *middleware.Context) {
 	tags := c.QueryStrings("tag")
 	starred := c.Query("starred")
 	limit := c.QueryInt("limit")
+	dashboardType := c.Query("type")
+	folderId := c.QueryInt64("folderId")
 
 	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)
 		}
@@ -30,11 +32,13 @@ func Search(c *middleware.Context) {
 	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,
+		FolderId:     folderId,
 	}
 
 	err := bus.Dispatch(&searchQuery)

+ 1 - 1
pkg/api/user.go

@@ -219,7 +219,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 {

+ 95 - 0
pkg/api/user_group.go

@@ -0,0 +1,95 @@
+package api
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/metrics"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+// POST /api/user-groups
+func CreateUserGroup(c *middleware.Context, cmd m.CreateUserGroupCommand) Response {
+	cmd.OrgId = c.OrgId
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrUserGroupNameTaken {
+			return ApiError(409, "User Group name taken", err)
+		}
+		return ApiError(500, "Failed to create User Group", err)
+	}
+
+	metrics.M_Api_UserGroup_Create.Inc(1)
+
+	return Json(200, &util.DynMap{
+		"userGroupId": cmd.Result.Id,
+		"message":     "User Group created",
+	})
+}
+
+// PUT /api/user-groups/:userGroupId
+func UpdateUserGroup(c *middleware.Context, cmd m.UpdateUserGroupCommand) Response {
+	cmd.Id = c.ParamsInt64(":userGroupId")
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrUserGroupNameTaken {
+			return ApiError(400, "User Group name taken", err)
+		}
+		return ApiError(500, "Failed to update User Group", err)
+	}
+
+	return ApiSuccess("User Group updated")
+}
+
+// DELETE /api/user-groups/:userGroupId
+func DeleteUserGroupById(c *middleware.Context) Response {
+	if err := bus.Dispatch(&m.DeleteUserGroupCommand{Id: c.ParamsInt64(":userGroupId")}); err != nil {
+		if err == m.ErrUserGroupNotFound {
+			return ApiError(404, "Failed to delete User Group. ID not found", nil)
+		}
+		return ApiError(500, "Failed to update User Group", err)
+	}
+	return ApiSuccess("User Group deleted")
+}
+
+// GET /api/user-groups/search
+func SearchUserGroups(c *middleware.Context) Response {
+	perPage := c.QueryInt("perpage")
+	if perPage <= 0 {
+		perPage = 1000
+	}
+	page := c.QueryInt("page")
+	if page < 1 {
+		page = 1
+	}
+
+	query := m.SearchUserGroupsQuery{
+		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 User Groups", err)
+	}
+
+	query.Result.Page = page
+	query.Result.PerPage = perPage
+
+	return Json(200, query.Result)
+}
+
+// GET /api/user-groups/:userGroupId
+func GetUserGroupById(c *middleware.Context) Response {
+	query := m.GetUserGroupByIdQuery{Id: c.ParamsInt64(":userGroupId")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		if err == m.ErrUserGroupNotFound {
+			return ApiError(404, "User Group not found", err)
+		}
+
+		return ApiError(500, "Failed to get User Group", err)
+	}
+
+	return Json(200, &query.Result)
+}

+ 44 - 0
pkg/api/user_group_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/user-groups/:userGroupId/members
+func GetUserGroupMembers(c *middleware.Context) Response {
+	query := m.GetUserGroupMembersQuery{UserGroupId: c.ParamsInt64(":userGroupId")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to get User Group Members", err)
+	}
+
+	return Json(200, query.Result)
+}
+
+// POST /api/user-groups/:userGroupId/members
+func AddUserGroupMember(c *middleware.Context, cmd m.AddUserGroupMemberCommand) Response {
+	cmd.UserGroupId = c.ParamsInt64(":userGroupId")
+	cmd.OrgId = c.OrgId
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrUserGroupMemberAlreadyAdded {
+			return ApiError(400, "User is already added to this user group", err)
+		}
+		return ApiError(500, "Failed to add Member to User Group", err)
+	}
+
+	return Json(200, &util.DynMap{
+		"message": "Member added to User Group",
+	})
+}
+
+// DELETE /api/user-groups/:userGroupId/members/:userId
+func RemoveUserGroupMember(c *middleware.Context) Response {
+	if err := bus.Dispatch(&m.RemoveUserGroupMemberCommand{UserGroupId: c.ParamsInt64(":userGroupId"), UserId: c.ParamsInt64(":userId")}); err != nil {
+		return ApiError(500, "Failed to remove Member from User Group", err)
+	}
+	return ApiSuccess("User Group Member removed")
+}

+ 71 - 0
pkg/api/user_group_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 TestUserGroupApiEndpoint(t *testing.T) {
+	Convey("Given two user groups", t, func() {
+		mockResult := models.SearchUserGroupQueryResult{
+			UserGroups: []*models.UserGroup{
+				{Name: "userGroup1"},
+				{Name: "userGroup2"},
+			},
+			TotalCount: 2,
+		}
+
+		Convey("When searching with no parameters", func() {
+			loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) {
+				var sentLimit int
+				var sendPage int
+				bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error {
+					query.Result = mockResult
+
+					sentLimit = query.Limit
+					sendPage = query.Page
+
+					return nil
+				})
+
+				sc.handlerFunc = SearchUserGroups
+				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("userGroups").MustArray()), ShouldEqual, 2)
+			})
+		})
+
+		Convey("When searching with page and perpage parameters", func() {
+			loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) {
+				var sentLimit int
+				var sendPage int
+				bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error {
+					query.Result = mockResult
+
+					sentLimit = query.Limit
+					sendPage = query.Page
+
+					return nil
+				})
+
+				sc.handlerFunc = SearchUserGroups
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
+
+				So(sentLimit, ShouldEqual, 10)
+				So(sendPage, ShouldEqual, 2)
+			})
+		})
+	})
+}

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

@@ -16,17 +16,21 @@ 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
 }
 
 var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
@@ -74,7 +78,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)

+ 5 - 0
pkg/metrics/metrics.go

@@ -35,6 +35,8 @@ var (
 	M_Api_Dashboard_Snapshot_Create        Counter
 	M_Api_Dashboard_Snapshot_External      Counter
 	M_Api_Dashboard_Snapshot_Get           Counter
+	M_Api_UserGroup_Create                 Counter
+	M_Api_Dashboard_Acl_Update             Counter
 	M_Models_Dashboard_Insert              Counter
 	M_Alerting_Result_State_Alerting       Counter
 	M_Alerting_Result_State_Ok             Counter
@@ -93,6 +95,9 @@ func initMetricVars(settings *MetricSettings) {
 	M_Api_User_SignUpCompleted = RegCounter("api.user.signup_completed")
 	M_Api_User_SignUpInvite = RegCounter("api.user.signup_invite")
 
+	M_Api_UserGroup_Create = RegCounter("api.usergroup.create")
+	M_Api_Dashboard_Acl_Update = RegCounter("api.dashboard.acl.update")
+
 	M_Api_Dashboard_Save = RegTimer("api.dashboard.save")
 	M_Api_Dashboard_Get = RegTimer("api.dashboard.get")
 	M_Api_Dashboard_Search = RegTimer("api.dashboard.search")

+ 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 user group 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
+	UserGroupId 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"`
+	UserGroupId    int64          `json:"userGroupId"`
+	UserGroup      string         `json:"userGroup"`
+	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
+	UserGroupId 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")
+		})
+	})
+}

+ 14 - 6
pkg/models/dashboards.go

@@ -11,11 +11,12 @@ import (
 
 // Typed errors
 var (
-	ErrDashboardNotFound           = errors.New("Dashboard not found")
-	ErrDashboardSnapshotNotFound   = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameNameExists = errors.New("A dashboard with the same name already exists")
-	ErrDashboardVersionMismatch    = errors.New("The dashboard has been changed by someone else")
-	ErrDashboardTitleEmpty         = errors.New("Dashboard title cannot be empty")
+	ErrDashboardNotFound               = errors.New("Dashboard not found")
+	ErrDashboardSnapshotNotFound       = errors.New("Dashboard snapshot not found")
+	ErrDashboardWithSameNameExists     = errors.New("A dashboard with the same name already exists")
+	ErrDashboardVersionMismatch        = errors.New("The dashboard has been changed by someone else")
+	ErrDashboardTitleEmpty             = errors.New("Dashboard title cannot be empty")
+	ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
 )
 
 type UpdatePluginDashboardError struct {
@@ -47,6 +48,9 @@ type Dashboard struct {
 
 	UpdatedBy int64
 	CreatedBy int64
+	FolderId  int64
+	IsFolder  bool
+	HasAcl    bool
 
 	Title string
 	Data  *simplejson.Json
@@ -111,6 +115,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
 }
@@ -138,12 +144,14 @@ type SaveDashboardCommand struct {
 	OrgId        int64            `json:"-"`
 	RestoredFrom int              `json:"-"`
 	PluginId     string           `json:"-"`
+	FolderId     int64            `json:"folderId"`
+	IsFolder     bool             `json:"isFolder"`
 
 	Result *Dashboard
 }
 
 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)
+		})
+	})
 }

+ 12 - 3
pkg/models/org_user.go

@@ -32,11 +32,20 @@ func (r RoleType) Includes(other RoleType) bool {
 	if r == ROLE_ADMIN {
 		return true
 	}
-	if r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR {
-		return other != ROLE_ADMIN
+
+	if other == ROLE_READ_ONLY_EDITOR {
+		return r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR
+	}
+
+	if other == ROLE_EDITOR {
+		return r == ROLE_EDITOR
+	}
+
+	if other == ROLE_VIEWER {
+		return r == ROLE_READ_ONLY_EDITOR || r == ROLE_EDITOR || r == ROLE_VIEWER
 	}
 
-	return r == other
+	return false
 }
 
 func (r *RoleType) UnmarshalJSON(data []byte) error {

+ 8 - 0
pkg/models/user.go

@@ -162,6 +162,14 @@ type SignedInUser struct {
 	HelpFlags1     HelpFlags1
 }
 
+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"`

+ 68 - 0
pkg/models/user_group.go

@@ -0,0 +1,68 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrUserGroupNotFound  = errors.New("User Group not found")
+	ErrUserGroupNameTaken = errors.New("User Group name is taken")
+)
+
+// UserGroup model
+type UserGroup 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 CreateUserGroupCommand struct {
+	Name  string `json:"name" binding:"Required"`
+	OrgId int64  `json:"-"`
+
+	Result UserGroup `json:"-"`
+}
+
+type UpdateUserGroupCommand struct {
+	Id   int64
+	Name string
+}
+
+type DeleteUserGroupCommand struct {
+	Id int64
+}
+
+type GetUserGroupByIdQuery struct {
+	Id     int64
+	Result *UserGroup
+}
+
+type GetUserGroupsByUserQuery struct {
+	UserId int64        `json:"userId"`
+	Result []*UserGroup `json:"userGroups"`
+}
+
+type SearchUserGroupsQuery struct {
+	Query string
+	Name  string
+	Limit int
+	Page  int
+	OrgId int64
+
+	Result SearchUserGroupQueryResult
+}
+
+type SearchUserGroupQueryResult struct {
+	TotalCount int64        `json:"totalCount"`
+	UserGroups []*UserGroup `json:"userGroups"`
+	Page       int          `json:"page"`
+	PerPage    int          `json:"perPage"`
+}

+ 55 - 0
pkg/models/user_group_member.go

@@ -0,0 +1,55 @@
+package models
+
+import (
+	"errors"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrUserGroupMemberAlreadyAdded = errors.New("User is already added to this user group")
+)
+
+// UserGroupMember model
+type UserGroupMember struct {
+	Id          int64
+	OrgId       int64
+	UserGroupId int64
+	UserId      int64
+
+	Created time.Time
+	Updated time.Time
+}
+
+// ---------------------
+// COMMANDS
+
+type AddUserGroupMemberCommand struct {
+	UserId      int64 `json:"userId" binding:"Required"`
+	OrgId       int64 `json:"-"`
+	UserGroupId int64 `json:"-"`
+}
+
+type RemoveUserGroupMemberCommand struct {
+	UserId      int64
+	UserGroupId int64
+}
+
+// ----------------------
+// QUERIES
+
+type GetUserGroupMembersQuery struct {
+	UserGroupId int64
+	Result      []*UserGroupMemberDTO
+}
+
+// ----------------------
+// Projections and DTOs
+
+type UserGroupMemberDTO struct {
+	OrgId       int64  `json:"orgId"`
+	UserGroupId int64  `json:"userGroupId"`
+	UserId      int64  `json:"userId"`
+	Email       string `json:"email"`
+	Login       string `json:"login"`
+}

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

+ 5 - 4
pkg/services/alerting/notifier.go

@@ -79,10 +79,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 {

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

@@ -0,0 +1,127 @@
+package guardian
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type DashboardGuardian struct {
+	user   *m.SignedInUser
+	dashId int64
+	orgId  int64
+	acl    []*m.DashboardAclInfoDTO
+	groups []*m.UserGroup
+	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) {
+	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
+	if orgRole == m.ROLE_READ_ONLY_EDITOR {
+		orgRole = m.ROLE_VIEWER
+	}
+
+	userGroupAclItems := []*m.DashboardAclInfoDTO{}
+
+	for _, p := range acl {
+		// user match
+		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.UserGroupId > 0 {
+			userGroupAclItems = append(userGroupAclItems, p)
+		}
+	}
+
+	// do we have group rules?
+	if len(userGroupAclItems) == 0 {
+		return false, nil
+	}
+
+	// load groups
+	userGroups, err := g.getUserGroups()
+	if err != nil {
+		return false, err
+	}
+
+	// evalute group rules
+	for _, p := range acl {
+		for _, ug := range userGroups {
+			if ug.Id == p.UserGroupId && 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) getUserGroups() ([]*m.UserGroup, error) {
+	if g.groups != nil {
+		return g.groups, nil
+	}
+
+	query := m.GetUserGroupsByUserQuery{UserId: g.user.UserId}
+	err := bus.Dispatch(&query)
+
+	g.groups = query.Result
+	return query.Result, err
+}

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

@@ -1,77 +1,35 @@
 package search
 
 import (
-	"log"
-	"path/filepath"
 	"sort"
 
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/setting"
 )
 
-var jsonDashIndex *JsonDashIndex
-
 func Init() {
 	bus.AddHandler("search", searchHandler)
-
-	jsonIndexCfg, _ := setting.Cfg.GetSection("dashboards.json")
-
-	if jsonIndexCfg == nil {
-		log.Fatal("Config section missing: dashboards.json")
-		return
-	}
-
-	jsonIndexEnabled := jsonIndexCfg.Key("enabled").MustBool(false)
-
-	if jsonIndexEnabled {
-		jsonFilesPath := jsonIndexCfg.Key("path").String()
-		if !filepath.IsAbs(jsonFilesPath) {
-			jsonFilesPath = filepath.Join(setting.HomePath, jsonFilesPath)
-		}
-
-		jsonDashIndex = NewJsonDashIndex(jsonFilesPath)
-		go jsonDashIndex.updateLoop()
-	}
 }
 
 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,
+		FolderId:     query.FolderId,
+		Tags:         query.Tags,
+		Limit:        query.Limit,
 	}
 
 	if err := bus.Dispatch(&dashQuery); err != nil {
 		return err
 	}
 
+	hits := make(HitList, 0)
 	hits = append(hits, dashQuery.Result...)
 
-	if jsonDashIndex != nil {
-		jsonHits, err := jsonDashIndex.Search(query)
-		if err != nil {
-			return err
-		}
-
-		hits = append(hits, jsonHits...)
-	}
-
-	// 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)
 
@@ -85,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
 	}
 
@@ -93,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 {
@@ -126,10 +65,3 @@ func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error {
 
 	return nil
 }
-
-func GetDashboardFromJsonIndex(filename string) *m.Dashboard {
-	if jsonDashIndex == nil {
-		return nil
-	}
-	return jsonDashIndex.GetDashboard(filename)
-}

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

@@ -11,14 +11,14 @@ import (
 func TestSearch(t *testing.T) {
 
 	Convey("Given search query", t, func() {
-		jsonDashIndex = NewJsonDashIndex("../../../public/dashboards/")
-		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
 		})
@@ -28,34 +28,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")
-			})
-
-		})
 	})
 }

+ 0 - 137
pkg/services/search/json_index.go

@@ -1,137 +0,0 @@
-package search
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/grafana/grafana/pkg/components/simplejson"
-	"github.com/grafana/grafana/pkg/log"
-	m "github.com/grafana/grafana/pkg/models"
-)
-
-type JsonDashIndex struct {
-	path  string
-	items []*JsonDashIndexItem
-}
-
-type JsonDashIndexItem struct {
-	TitleLower string
-	TagsCsv    string
-	Path       string
-	Dashboard  *m.Dashboard
-}
-
-func NewJsonDashIndex(path string) *JsonDashIndex {
-	log.Info("Creating json dashboard index for path: %v", path)
-
-	index := JsonDashIndex{}
-	index.path = path
-	index.updateIndex()
-	return &index
-}
-
-func (index *JsonDashIndex) updateLoop() {
-	ticker := time.NewTicker(time.Minute)
-	for {
-		select {
-		case <-ticker.C:
-			if err := index.updateIndex(); err != nil {
-				log.Error(3, "Failed to update dashboard json index %v", err)
-			}
-		}
-	}
-}
-
-func (index *JsonDashIndex) Search(query *Query) ([]*Hit, error) {
-	results := make([]*Hit, 0)
-
-	if query.IsStarred {
-		return results, nil
-	}
-
-	queryStr := strings.ToLower(query.Title)
-
-	for _, item := range index.items {
-		if len(results) > query.Limit {
-			break
-		}
-
-		// add results with matchig title filter
-		if strings.Contains(item.TitleLower, queryStr) {
-			results = append(results, &Hit{
-				Type:  DashHitJson,
-				Title: item.Dashboard.Title,
-				Tags:  item.Dashboard.GetTags(),
-				Uri:   "file/" + item.Path,
-			})
-		}
-	}
-
-	return results, nil
-}
-
-func (index *JsonDashIndex) GetDashboard(path string) *m.Dashboard {
-	for _, item := range index.items {
-		if item.Path == path {
-			return item.Dashboard
-		}
-	}
-
-	return nil
-}
-
-func (index *JsonDashIndex) updateIndex() error {
-	var items = make([]*JsonDashIndexItem, 0)
-
-	visitor := func(path string, f os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-		if f.IsDir() {
-			return nil
-		}
-
-		if strings.HasSuffix(f.Name(), ".json") {
-			dash, err := loadDashboardFromFile(path)
-			if err != nil {
-				return err
-			}
-
-			items = append(items, dash)
-		}
-
-		return nil
-	}
-
-	if err := filepath.Walk(index.path, visitor); err != nil {
-		return err
-	}
-
-	index.items = items
-	return nil
-}
-
-func loadDashboardFromFile(filename string) (*JsonDashIndexItem, error) {
-	reader, err := os.Open(filename)
-	if err != nil {
-		return nil, err
-	}
-	defer reader.Close()
-
-	data, err := simplejson.NewFromReader(reader)
-	if err != nil {
-		return nil, err
-	}
-
-	stat, _ := os.Stat(filename)
-
-	item := &JsonDashIndexItem{}
-	item.Dashboard = m.NewDashboardFromJson(data)
-	item.TitleLower = strings.ToLower(item.Dashboard.Title)
-	item.TagsCsv = strings.Join(item.Dashboard.GetTags(), ",")
-	item.Path = stat.Name()
-
-	return item, nil
-}

+ 0 - 42
pkg/services/search/json_index_test.go

@@ -1,42 +0,0 @@
-package search
-
-import (
-	"testing"
-
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestJsonDashIndex(t *testing.T) {
-
-	Convey("Given the json dash index", t, func() {
-		index := NewJsonDashIndex("../../../public/dashboards/")
-
-		Convey("Should be able to update index", func() {
-			err := index.updateIndex()
-			So(err, ShouldBeNil)
-		})
-
-		Convey("Should be able to search index", func() {
-			res, err := index.Search(&Query{Title: "", Limit: 20})
-			So(err, ShouldBeNil)
-
-			So(len(res), ShouldEqual, 3)
-		})
-
-		Convey("Should be able to search index by title", func() {
-			res, err := index.Search(&Query{Title: "home", Limit: 20})
-			So(err, ShouldBeNil)
-
-			So(len(res), ShouldEqual, 1)
-			So(res[0].Title, ShouldEqual, "Home")
-		})
-
-		Convey("Should not return when starred is filtered", func() {
-			res, err := index.Search(&Query{Title: "", IsStarred: true})
-			So(err, ShouldBeNil)
-
-			So(len(res), ShouldEqual, 0)
-		})
-
-	})
-}

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

@@ -1,37 +1,54 @@
 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"`
+	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
+	FolderId     int64
 
 	Result HitList
 }
@@ -39,9 +56,13 @@ type Query struct {
 type FindPersistedDashboardsQuery struct {
 	Title        string
 	OrgId        int64
-	UserId       int64
+	SignedInUser *models.SignedInUser
 	IsStarred    bool
-	DashboardIds []int
+	DashboardIds []int64
+	Type         string
+	FolderId     int64
+	Tags         []string
+	Limit        int
 
 	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)

+ 152 - 35
pkg/services/sqlstore/dashboard.go

@@ -3,6 +3,7 @@ package sqlstore
 import (
 	"bytes"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -70,6 +71,11 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			}
 		}
 
+		err = setHasAcl(sess, dash)
+		if err != nil {
+			return err
+		}
+
 		parentVersion := dash.Version
 		affectedRows := int64(0)
 
@@ -79,9 +85,9 @@ 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)
-			affectedRows, err = sess.Id(dash.Id).Update(dash)
+			affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
 		}
 
 		if err != nil {
@@ -110,7 +116,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
@@ -125,13 +131,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)
@@ -148,48 +178,94 @@ 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 {
+func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) {
+	limit := query.Limit
+	if limit == 0 {
+		limit = 1000
+	}
+
 	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`)
+	sql.WriteString(`
+	SELECT
+		dashboard.id,
+		dashboard.title,
+		dashboard.slug,
+		dashboard_tag.term,
+		dashboard.is_folder,
+		dashboard.folder_id,
+		folder.slug as folder_slug,
+		folder.title as folder_title
+	FROM (
+		SELECT
+			dashboard.id FROM dashboard
+			LEFT OUTER JOIN dashboard_tag ON dashboard_tag.dashboard_id = dashboard.id
+	`)
+
+	// add tags filter
+	if len(query.Tags) > 0 {
+		sql.WriteString(` WHERE dashboard_tag.term IN (?` + strings.Repeat(",?", len(query.Tags)-1) + `)`)
+		for _, tag := range query.Tags {
+			params = append(params, tag)
+		}
+	}
 
+	// this ends the inner select (tag filtered part)
+	sql.WriteString(`
+		  GROUP BY dashboard.id HAVING COUNT(dashboard.id) >= ?
+		  ORDER BY dashboard.title ASC LIMIT ?) as ids`)
+	params = append(params, len(query.Tags))
+	params = append(params, limit)
+
+	sql.WriteString(`
+		INNER JOIN dashboard on ids.id = dashboard.id
+		LEFT OUTER JOIN dashboard folder on folder.id = dashboard.folder_id
+		LEFT OUTER JOIN dashboard_tag on dashboard.id = dashboard_tag.dashboard_id`)
 	if query.IsStarred {
 		sql.WriteString(" INNER JOIN star on star.dashboard_id = dashboard.id")
 	}
 
 	sql.WriteString(` WHERE dashboard.org_id=?`)
-
-	params = append(params, query.OrgId)
+	params = append(params, query.SignedInUser.OrgId)
 
 	if query.IsStarred {
 		sql.WriteString(` AND star.user_id=?`)
-		params = append(params, query.UserId)
+		params = append(params, query.SignedInUser.UserId)
 	}
 
 	if len(query.DashboardIds) > 0 {
-		sql.WriteString(" AND (")
-		for i, dashboardId := range query.DashboardIds {
-			if i != 0 {
-				sql.WriteString(" OR")
-			}
-
-			sql.WriteString(" dashboard.id = ?")
+		sql.WriteString(` AND dashboard.id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `)`)
+		for _, dashboardId := range query.DashboardIds {
 			params = append(params, dashboardId)
 		}
-		sql.WriteString(")")
+	}
+
+	if query.SignedInUser.OrgRole != m.ROLE_ADMIN {
+		allowedDashboardsSubQuery := ` AND (dashboard.has_acl = 0 OR dashboard.id in (
+		SELECT distinct d.id AS DashboardId
+			FROM dashboard AS d
+	      LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id
+	      LEFT JOIN user_group_member as ugm on ugm.user_group_id =  da.user_group_id
+	      LEFT JOIN org_user ou on ou.role = da.role
+			WHERE
+			  d.has_acl = 1 and
+				(da.user_id = ? or ugm.user_id = ? or ou.id is not null)
+			  and d.org_id = ?
+			  ))`
+
+		sql.WriteString(allowedDashboardsSubQuery)
+		params = append(params, query.SignedInUser.UserId, query.SignedInUser.UserId, query.SignedInUser.OrgId)
 	}
 
 	if len(query.Title) > 0 {
@@ -197,15 +273,54 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
 		params = append(params, "%"+query.Title+"%")
 	}
 
+	if len(query.Type) > 0 && query.Type == "dash-folder" {
+		sql.WriteString(" AND dashboard.is_folder = 1")
+	}
+
+	if len(query.Type) > 0 && query.Type == "dash-db" {
+		sql.WriteString(" AND dashboard.is_folder = 0")
+	}
+
+	if query.FolderId > 0 {
+		sql.WriteString(" AND dashboard.folder_id = ?")
+		params = append(params, query.FolderId)
+	}
+
 	sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000"))
 
 	var res []DashboardSearchProjection
 
 	err := x.Sql(sql.String(), 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)
 
@@ -213,11 +328,14 @@ 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,
+				Type:        getHitType(item),
+				FolderId:    item.FolderId,
+				FolderTitle: item.FolderTitle,
+				FolderSlug:  item.FolderSlug,
+				Tags:        []string{},
 			}
 			query.Result = append(query.Result, hit)
 			hits[item.Id] = hit
@@ -226,8 +344,6 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
 			hit.Tags = append(hit.Tags, item.Term)
 		}
 	}
-
-	return err
 }
 
 func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
@@ -247,7 +363,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
@@ -261,6 +377,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 = ?",
 		}
 
 		for _, sql := range deletes {

+ 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.UserGroupId == 0 && !item.Role.IsValid() {
+				return m.ErrDashboardAclInfoMissing
+			}
+
+			if item.DashboardId == 0 {
+				return m.ErrDashboardPermissionDashboardEmpty
+			}
+
+			sess.Nullable("user_id", "user_group_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.UserGroupId == 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 (user_group_id=? or user_id=?)", cmd.DashboardId, cmd.UserGroupId, 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 (user_group_id=? or user_id=?)", cmd.DashboardId, cmd.UserGroupId, cmd.UserId).Update(&entity); err != nil {
+				return err
+			}
+
+			return nil
+		}
+
+		entity := m.DashboardAcl{
+			OrgId:       cmd.OrgId,
+			UserGroupId: cmd.UserGroupId,
+			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.UserGroupId != 0 {
+			cols = append(cols, "user_group_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.user_group_id,
+		da.permission,
+		da.role,
+		da.created,
+		da.updated,
+		u.login AS user_login,
+		u.email AS user_email,
+		ug.name AS user_group
+  FROM` + dialect.Quote("dashboard_acl") + ` as da
+		LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
+		LEFT OUTER JOIN user_group ug on ug.id = da.user_group_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.user_group_id,
+			da.permission,
+			da.role,
+			da.created,
+			da.updated,
+			'' as user_login,
+			'' as user_email,
+			'' as user_group
+			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 userGroupId 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 user group", func() {
+				group1 := m.CreateUserGroupCommand{Name: "group1 name", OrgId: 1}
+				err := CreateUserGroup(&group1)
+				So(err, ShouldBeNil)
+
+				Convey("Should be able to add a user permission for a user group", func() {
+					setDashAclCmd := m.SetDashboardAclCommand{
+						OrgId:       1,
+						UserGroupId: 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].UserGroupId, ShouldEqual, group1.Result.Id)
+
+					Convey("Should be able to delete an existing permission for a user group", 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 user group", func() {
+					err := SetDashboardAcl(&m.SetDashboardAclCommand{
+						OrgId:       1,
+						UserGroupId: 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].UserGroupId, ShouldEqual, group1.Result.Id)
+				})
+
+			})
+		})
+	})
+}

+ 350 - 34
pkg/services/sqlstore/dashboard_test.go

@@ -5,42 +5,35 @@ import (
 
 	. "github.com/smartystreets/goconvey/convey"
 
-	"github.com/gosimple/slug"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
+	"github.com/grafana/grafana/pkg/setting"
 )
 
-func insertTestDashboard(title string, orgId int64, tags ...interface{}) *m.Dashboard {
-	cmd := m.SaveDashboardCommand{
-		OrgId: orgId,
-		Dashboard: simplejson.NewFromAny(map[string]interface{}{
-			"id":    nil,
-			"title": title,
-			"tags":  tags,
-		}),
-	}
-
-	err := SaveDashboard(&cmd)
-	So(err, ShouldBeNil)
-
-	return cmd.Result
-}
-
 func TestDashboardDataAccess(t *testing.T) {
 
 	Convey("Testing DB", t, func() {
 		InitTestDB(t)
 
 		Convey("Given saved dashboard", func() {
-			savedDash := insertTestDashboard("test dash 23", 1, "prod", "webapp")
-			insertTestDashboard("test dash 45", 1, "prod")
-			insertTestDashboard("test dash 67", 1, "prod", "webapp")
+			savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
+			savedDash := insertTestDashboard("test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
+			insertTestDashboard("test dash 45", 1, savedFolder.Id, false, "prod")
+			insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
 
 			Convey("Should return dashboard model", func() {
 				So(savedDash.Title, ShouldEqual, "test dash 23")
 				So(savedDash.Slug, ShouldEqual, "test-dash-23")
 				So(savedDash.Id, ShouldNotEqual, 0)
+				So(savedDash.IsFolder, ShouldBeFalse)
+				So(savedDash.FolderId, ShouldBeGreaterThan, 0)
+
+				So(savedFolder.Title, ShouldEqual, "1 test dash folder")
+				So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
+				So(savedFolder.Id, ShouldNotEqual, 0)
+				So(savedFolder.IsFolder, ShouldBeTrue)
+				So(savedFolder.FolderId, ShouldEqual, 0)
 			})
 
 			Convey("Should be able to get dashboard", func() {
@@ -54,15 +47,14 @@ func TestDashboardDataAccess(t *testing.T) {
 
 				So(query.Result.Title, ShouldEqual, "test dash 23")
 				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.IsFolder, ShouldBeFalse)
 			})
 
 			Convey("Should be able to delete dashboard", func() {
-				insertTestDashboard("delete me", 1, "delete this")
-
-				dashboardSlug := slug.Make("delete me")
+				dash := insertTestDashboard("delete me", 1, 0, false, "delete this")
 
 				err := DeleteDashboard(&m.DeleteDashboardCommand{
-					Slug:  dashboardSlug,
+					Id:    dash.Id,
 					OrgId: 1,
 				})
 
@@ -102,10 +94,11 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldNotBeNil)
 			})
 
-			Convey("Should be able to search for dashboard", func() {
+			Convey("Should be able to search for dashboard folder", func() {
 				query := search.FindPersistedDashboardsQuery{
-					Title: "test dash 23",
-					OrgId: 1,
+					Title:        "1 test dash folder",
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{OrgId: 1},
 				}
 
 				err := SearchDashboards(&query)
@@ -113,14 +106,29 @@ func TestDashboardDataAccess(t *testing.T) {
 
 				So(len(query.Result), ShouldEqual, 1)
 				hit := query.Result[0]
-				So(len(hit.Tags), ShouldEqual, 2)
+				So(hit.Type, ShouldEqual, search.DashHitFolder)
+			})
+
+			Convey("Should be able to search for a dashboard folder's children", func() {
+				query := search.FindPersistedDashboardsQuery{
+					OrgId:        1,
+					FolderId:     savedFolder.Id,
+					SignedInUser: &m.SignedInUser{OrgId: 1},
+				}
+
+				err := SearchDashboards(&query)
+				So(err, ShouldBeNil)
+
+				So(len(query.Result), ShouldEqual, 2)
+				hit := query.Result[0]
+				So(hit.Id, ShouldEqual, savedDash.Id)
 			})
 
 			Convey("Should be able to search for dashboard by dashboard ids", func() {
 				Convey("should be able to find two dashboards by id", func() {
 					query := search.FindPersistedDashboardsQuery{
-						DashboardIds: []int{1, 2},
-						OrgId:        1,
+						DashboardIds: []int64{2, 3},
+						SignedInUser: &m.SignedInUser{OrgId: 1},
 					}
 
 					err := SearchDashboards(&query)
@@ -137,8 +145,8 @@ func TestDashboardDataAccess(t *testing.T) {
 
 				Convey("DashboardIds that does not exists should not cause errors", func() {
 					query := search.FindPersistedDashboardsQuery{
-						DashboardIds: []int{1000},
-						OrgId:        1,
+						DashboardIds: []int64{1000},
+						SignedInUser: &m.SignedInUser{OrgId: 1},
 					}
 
 					err := SearchDashboards(&query)
@@ -161,6 +169,63 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldNotBeNil)
 			})
 
+			Convey("Should be able to update dashboard and remove folderId", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    1,
+						"title": "folderId",
+						"tags":  []interface{}{},
+					}),
+					Overwrite: true,
+					FolderId:  2,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.FolderId, ShouldEqual, 2)
+
+				cmd = m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    1,
+						"title": "folderId",
+						"tags":  []interface{}{},
+					}),
+					FolderId:  0,
+					Overwrite: true,
+				}
+
+				err = SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+
+				query := m.GetDashboardQuery{
+					Slug:  cmd.Result.Slug,
+					OrgId: 1,
+				}
+
+				err = GetDashboard(&query)
+				So(err, ShouldBeNil)
+				So(query.Result.FolderId, ShouldEqual, 0)
+			})
+
+			Convey("Should be able to delete a dashboard folder and its children", func() {
+				deleteCmd := &m.DeleteDashboardCommand{Id: savedFolder.Id}
+				err := DeleteDashboard(deleteCmd)
+				So(err, ShouldBeNil)
+
+				query := search.FindPersistedDashboardsQuery{
+					OrgId:        1,
+					FolderId:     savedFolder.Id,
+					SignedInUser: &m.SignedInUser{},
+				}
+
+				err = SearchDashboards(&query)
+				So(err, ShouldBeNil)
+
+				So(len(query.Result), ShouldEqual, 0)
+			})
+
 			Convey("Should be able to get dashboard tags", func() {
 				query := m.GetDashboardTagsQuery{OrgId: 1}
 
@@ -171,7 +236,7 @@ func TestDashboardDataAccess(t *testing.T) {
 			})
 
 			Convey("Given two dashboards, one is starred dashboard by user 10, other starred by user 1", func() {
-				starredDash := insertTestDashboard("starred dash", 1)
+				starredDash := insertTestDashboard("starred dash", 1, 0, false)
 				StarDashboard(&m.StarDashboardCommand{
 					DashboardId: starredDash.Id,
 					UserId:      10,
@@ -183,7 +248,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				})
 
 				Convey("Should be able to search for starred dashboards", func() {
-					query := search.FindPersistedDashboardsQuery{OrgId: 1, UserId: 10, IsStarred: true}
+					query := search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: 10, OrgId: 1}, IsStarred: true}
 					err := SearchDashboards(&query)
 
 					So(err, ShouldBeNil)
@@ -192,5 +257,256 @@ func TestDashboardDataAccess(t *testing.T) {
 				})
 			})
 		})
+
+		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
+			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
+			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
+			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+
+			Convey("and no acls are set", func() {
+				Convey("should return all dashboards", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder.Id)
+					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission", func() {
+					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+
+			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
+				var otherUser int64 = 999
+				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+				removeAcl(aclId)
+				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder or child", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission to child", func() {
+					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to search for child dashboard but not folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, childDash.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to search for child dash and folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
+			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
+			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+
+			Convey("and acl is set for one dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
+					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should not return folder with acl or its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 1)
+						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeFalse)
+
+					Convey("should return folder without acl and its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 4)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
+						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
+					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should return folder without acl but not the dashboard with acl", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
 	})
 }
+
+func insertTestDashboard(title string, orgId int64, folderId int64, isFolder bool, tags ...interface{}) *m.Dashboard {
+	cmd := m.SaveDashboardCommand{
+		OrgId:    orgId,
+		FolderId: folderId,
+		IsFolder: isFolder,
+		Dashboard: simplejson.NewFromAny(map[string]interface{}{
+			"id":    nil,
+			"title": title,
+			"tags":  tags,
+		}),
+	}
+
+	err := SaveDashboard(&cmd)
+	So(err, ShouldBeNil)
+
+	return cmd.Result
+}
+
+func createUser(name string, role string, isAdmin bool) m.User {
+	setting.AutoAssignOrg = true
+	setting.AutoAssignOrgRole = role
+
+	currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin}
+	err := CreateUser(&currentUserCmd)
+	So(err, ShouldBeNil)
+
+	q1 := m.GetUserOrgListQuery{UserId: currentUserCmd.Result.Id}
+	GetUserOrgList(&q1)
+	So(q1.Result[0].Role, ShouldEqual, role)
+
+	return currentUserCmd.Result
+}
+
+func updateTestDashboardWithAcl(dashId int64, userId int64, permissions m.PermissionType) int64 {
+	cmd := &m.SetDashboardAclCommand{
+		OrgId:       1,
+		UserId:      userId,
+		DashboardId: dashId,
+		Permission:  permissions,
+	}
+
+	err := SetDashboardAcl(cmd)
+	So(err, ShouldBeNil)
+
+	return cmd.Result.Id
+}
+
+func removeAcl(aclId int64) {
+	err := RemoveDashboardAcl(&m.RemoveDashboardAclCommand{AclId: aclId, OrgId: 1})
+	So(err, ShouldBeNil)
+}
+
+func moveDashboard(orgId int64, dashboard *simplejson.Json, newFolderId int64) *m.Dashboard {
+	cmd := m.SaveDashboardCommand{
+		OrgId:     orgId,
+		FolderId:  newFolderId,
+		Dashboard: dashboard,
+		Overwrite: true,
+	}
+
+	err := SaveDashboard(&cmd)
+	So(err, ShouldBeNil)
+
+	return cmd.Result
+}

+ 4 - 0
pkg/services/sqlstore/dashboard_version.go

@@ -32,6 +32,10 @@ func GetDashboardVersion(query *m.GetDashboardVersionQuery) error {
 
 // GetDashboardVersions gets all dashboard versions for the given dashboard ID.
 func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error {
+	if query.Limit == 0 {
+		query.Limit = 1000
+	}
+
 	err := x.Table("dashboard_version").
 		Select(`dashboard_version.id,
 				dashboard_version.dashboard_id,

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

@@ -28,7 +28,7 @@ func TestGetDashboardVersion(t *testing.T) {
 		InitTestDB(t)
 
 		Convey("Get a Dashboard ID and version ID", func() {
-			savedDash := insertTestDashboard("test dash 26", 1, "diff")
+			savedDash := insertTestDashboard("test dash 26", 1, 0, false, "diff")
 
 			query := m.GetDashboardVersionQuery{
 				DashboardId: savedDash.Id,
@@ -69,7 +69,7 @@ func TestGetDashboardVersion(t *testing.T) {
 func TestGetDashboardVersions(t *testing.T) {
 	Convey("Testing dashboard versions retrieval", t, func() {
 		InitTestDB(t)
-		savedDash := insertTestDashboard("test dash 43", 1, "diff-all")
+		savedDash := insertTestDashboard("test dash 43", 1, 0, false, "diff-all")
 
 		Convey("Get all versions for a given Dashboard ID", func() {
 			query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1}

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

@@ -15,6 +15,7 @@ func InitTestDB(t *testing.T) {
 	x, err := xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr)
 	//x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
 	//x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
+	// x.ShowSQL()
 
 	// x.ShowSQL()
 

+ 52 - 0
pkg/services/sqlstore/migrations/dashboard_acl.go

@@ -0,0 +1,52 @@
+package migrations
+
+import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+
+func addDashboardAclMigrations(mg *Migrator) {
+	dashboardAclV1 := Table{
+		Name: "dashboard_acl",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "org_id", Type: DB_BigInt},
+			{Name: "dashboard_id", Type: DB_BigInt},
+			{Name: "user_id", Type: DB_BigInt, Nullable: true},
+			{Name: "user_group_id", Type: DB_BigInt, Nullable: true},
+			{Name: "permission", Type: DB_SmallInt, Default: "4"},
+			{Name: "role", Type: DB_Varchar, Length: 20, Nullable: true},
+			{Name: "created", Type: DB_DateTime, Nullable: false},
+			{Name: "updated", Type: DB_DateTime, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"dashboard_id"}},
+			{Cols: []string{"dashboard_id", "user_id"}, Type: UniqueIndex},
+			{Cols: []string{"dashboard_id", "user_group_id"}, Type: UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("create dashboard acl table", NewAddTableMigration(dashboardAclV1))
+
+	//-------  indexes ------------------
+	mg.AddMigration("add unique index dashboard_acl_dashboard_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[0]))
+	mg.AddMigration("add unique index dashboard_acl_dashboard_id_user_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[1]))
+	mg.AddMigration("add unique index dashboard_acl_dashboard_id_group_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[2]))
+
+	const rawSQL = `
+INSERT INTO dashboard_acl
+	(
+		org_id,
+		dashboard_id,
+		permission,
+		role,
+		created,
+		updated
+	)
+	VALUES
+		(-1,-1, 1,'Viewer','2017-06-20','2017-06-20'),
+		(-1,-1, 2,'Editor','2017-06-20','2017-06-20')
+	`
+
+	mg.AddMigration("save default acl rules in dashboard_acl table", new(RawSqlMigration).
+		Sqlite(rawSQL).
+		Postgres(rawSQL).
+		Mysql(rawSQL))
+}

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

@@ -136,4 +136,18 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("Update dashboard_tag table charset", NewTableCharsetMigration("dashboard_tag", []*Column{
 		{Name: "term", Type: DB_NVarchar, Length: 50, Nullable: false},
 	}))
+
+	// add column to store folder_id for dashboard folder structure
+	mg.AddMigration("Add column folder_id in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "folder_id", Type: DB_BigInt, Nullable: true,
+	}))
+
+	mg.AddMigration("Add column isFolder in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "is_folder", Type: DB_Bool, Nullable: false, Default: "0",
+	}))
+
+	// add column to flag if dashboard has an ACL
+	mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0",
+	}))
 }

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

@@ -26,6 +26,8 @@ func AddMigrations(mg *Migrator) {
 	addAnnotationMig(mg)
 	addTestDataMigrations(mg)
 	addDashboardVersionMigration(mg)
+	addUserGroupMigrations(mg)
+	addDashboardAclMigrations(mg)
 }
 
 func addMigrationLogMigrations(mg *Migrator) {

+ 48 - 0
pkg/services/sqlstore/migrations/user_group_mig.go

@@ -0,0 +1,48 @@
+package migrations
+
+import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+
+func addUserGroupMigrations(mg *Migrator) {
+	userGroupV1 := Table{
+		Name: "user_group",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false},
+			{Name: "org_id", Type: DB_BigInt},
+			{Name: "created", Type: DB_DateTime, Nullable: false},
+			{Name: "updated", Type: DB_DateTime, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"org_id"}},
+			{Cols: []string{"org_id", "name"}, Type: UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("create user group table", NewAddTableMigration(userGroupV1))
+
+	//-------  indexes ------------------
+	mg.AddMigration("add index user_group.org_id", NewAddIndexMigration(userGroupV1, userGroupV1.Indices[0]))
+	mg.AddMigration("add unique index user_group_org_id_name", NewAddIndexMigration(userGroupV1, userGroupV1.Indices[1]))
+
+	userGroupMemberV1 := Table{
+		Name: "user_group_member",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "org_id", Type: DB_BigInt},
+			{Name: "user_group_id", Type: DB_BigInt},
+			{Name: "user_id", Type: DB_BigInt},
+			{Name: "created", Type: DB_DateTime, Nullable: false},
+			{Name: "updated", Type: DB_DateTime, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"org_id"}},
+			{Cols: []string{"org_id", "user_group_id", "user_id"}, Type: UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("create user group member table", NewAddTableMigration(userGroupMemberV1))
+
+	//-------  indexes ------------------
+	mg.AddMigration("add index user_group_member.org_id", NewAddIndexMigration(userGroupMemberV1, userGroupMemberV1.Indices[0]))
+	mg.AddMigration("add unique index user_group_member_org_id_user_group_id_user_id", NewAddIndexMigration(userGroupMemberV1, userGroupMemberV1.Indices[1]))
+}

+ 51 - 0
pkg/services/sqlstore/org_test.go

@@ -154,6 +154,57 @@ func TestAccountDataAccess(t *testing.T) {
 					So(err, ShouldEqual, m.ErrLastOrgAdmin)
 				})
 
+				Convey("Given an org user with dashboard permissions", func() {
+					ac3cmd := m.CreateUserCommand{Login: "ac3", Email: "ac3@test.com", Name: "ac3 name", IsAdmin: false}
+					err := CreateUser(&ac3cmd)
+					So(err, ShouldBeNil)
+					ac3 := ac3cmd.Result
+
+					orgUserCmd := m.AddOrgUserCommand{
+						OrgId:  ac1.OrgId,
+						UserId: ac3.Id,
+						Role:   m.ROLE_VIEWER,
+					}
+
+					err = AddOrgUser(&orgUserCmd)
+					So(err, ShouldBeNil)
+
+					query := m.GetOrgUsersQuery{OrgId: ac1.OrgId}
+					err = GetOrgUsers(&query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 3)
+
+					err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: ac1.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT})
+					So(err, ShouldBeNil)
+
+					err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 2, OrgId: ac3.OrgId, UserId: ac3.Id, Permission: m.PERMISSION_EDIT})
+					So(err, ShouldBeNil)
+
+					Convey("When org user is deleted", func() {
+						cmdRemove := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac3.Id}
+						err := RemoveOrgUser(&cmdRemove)
+						So(err, ShouldBeNil)
+
+						Convey("Should remove dependent permissions for deleted org user", func() {
+							permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: ac1.OrgId}
+							err = GetDashboardAclInfoList(permQuery)
+							So(err, ShouldBeNil)
+
+							So(len(permQuery.Result), ShouldEqual, 0)
+						})
+
+						Convey("Should not remove dashboard permissions for same user in another org", func() {
+							permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 2, OrgId: ac3.OrgId}
+							err = GetDashboardAclInfoList(permQuery)
+							So(err, ShouldBeNil)
+
+							So(len(permQuery.Result), ShouldEqual, 1)
+							So(permQuery.Result[0].OrgId, ShouldEqual, ac3.OrgId)
+							So(permQuery.Result[0].UserId, ShouldEqual, ac3.Id)
+						})
+
+					})
+				})
 			})
 		})
 	})

+ 11 - 4
pkg/services/sqlstore/org_users.go

@@ -80,10 +80,17 @@ func GetOrgUsers(query *m.GetOrgUsersQuery) error {
 
 func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		var rawSql = "DELETE FROM org_user WHERE org_id=? and user_id=?"
-		_, err := sess.Exec(rawSql, cmd.OrgId, cmd.UserId)
-		if err != nil {
-			return err
+		deletes := []string{
+			"DELETE FROM org_user WHERE org_id=? and user_id=?",
+			"DELETE FROM dashboard_acl WHERE org_id=? and user_id = ?",
+			"DELETE FROM user_group_member WHERE org_id=? and user_id = ?",
+		}
+
+		for _, sql := range deletes {
+			_, err := sess.Exec(sql, cmd.OrgId, cmd.UserId)
+			if err != nil {
+				return err
+			}
 		}
 
 		return validateOneAdminLeftInOrg(cmd.OrgId, sess)

+ 4 - 0
pkg/services/sqlstore/user.go

@@ -396,6 +396,10 @@ func DeleteUser(cmd *m.DeleteUserCommand) error {
 		deletes := []string{
 			"DELETE FROM star WHERE user_id = ?",
 			"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
+			"DELETE FROM org_user WHERE user_id = ?",
+			"DELETE FROM dashboard_acl WHERE user_id = ?",
+			"DELETE FROM preferences WHERE user_id = ?",
+			"DELETE FROM user_group_member WHERE user_id = ?",
 		}
 
 		for _, sql := range deletes {

+ 233 - 0
pkg/services/sqlstore/user_group.go

@@ -0,0 +1,233 @@
+package sqlstore
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func init() {
+	bus.AddHandler("sql", CreateUserGroup)
+	bus.AddHandler("sql", UpdateUserGroup)
+	bus.AddHandler("sql", DeleteUserGroup)
+	bus.AddHandler("sql", SearchUserGroups)
+	bus.AddHandler("sql", GetUserGroupById)
+	bus.AddHandler("sql", GetUserGroupsByUser)
+
+	bus.AddHandler("sql", AddUserGroupMember)
+	bus.AddHandler("sql", RemoveUserGroupMember)
+	bus.AddHandler("sql", GetUserGroupMembers)
+}
+
+func CreateUserGroup(cmd *m.CreateUserGroupCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+
+		if isNameTaken, err := isUserGroupNameTaken(cmd.Name, 0, sess); err != nil {
+			return err
+		} else if isNameTaken {
+			return m.ErrUserGroupNameTaken
+		}
+
+		userGroup := m.UserGroup{
+			Name:    cmd.Name,
+			OrgId:   cmd.OrgId,
+			Created: time.Now(),
+			Updated: time.Now(),
+		}
+
+		_, err := sess.Insert(&userGroup)
+
+		cmd.Result = userGroup
+
+		return err
+	})
+}
+
+func UpdateUserGroup(cmd *m.UpdateUserGroupCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+
+		if isNameTaken, err := isUserGroupNameTaken(cmd.Name, cmd.Id, sess); err != nil {
+			return err
+		} else if isNameTaken {
+			return m.ErrUserGroupNameTaken
+		}
+
+		userGroup := m.UserGroup{
+			Name:    cmd.Name,
+			Updated: time.Now(),
+		}
+
+		affectedRows, err := sess.Id(cmd.Id).Update(&userGroup)
+
+		if err != nil {
+			return err
+		}
+
+		if affectedRows == 0 {
+			return m.ErrUserGroupNotFound
+		}
+
+		return nil
+	})
+}
+
+func DeleteUserGroup(cmd *m.DeleteUserGroupCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		if res, err := sess.Query("SELECT 1 from user_group WHERE id=?", cmd.Id); err != nil {
+			return err
+		} else if len(res) != 1 {
+			return m.ErrUserGroupNotFound
+		}
+
+		deletes := []string{
+			"DELETE FROM user_group_member WHERE user_group_id = ?",
+			"DELETE FROM user_group WHERE id = ?",
+			"DELETE FROM dashboard_acl WHERE user_group_id = ?",
+		}
+
+		for _, sql := range deletes {
+			_, err := sess.Exec(sql, cmd.Id)
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func isUserGroupNameTaken(name string, existingId int64, sess *DBSession) (bool, error) {
+	var userGroup m.UserGroup
+	exists, err := sess.Where("name=?", name).Get(&userGroup)
+
+	if err != nil {
+		return false, nil
+	}
+
+	if exists && existingId != userGroup.Id {
+		return true, nil
+	}
+
+	return false, nil
+}
+
+func SearchUserGroups(query *m.SearchUserGroupsQuery) error {
+	query.Result = m.SearchUserGroupQueryResult{
+		UserGroups: make([]*m.UserGroup, 0),
+	}
+	queryWithWildcards := "%" + query.Query + "%"
+
+	sess := x.Table("user_group")
+	sess.Where("org_id=?", query.OrgId)
+
+	if query.Query != "" {
+		sess.Where("name LIKE ?", queryWithWildcards)
+	}
+	if query.Name != "" {
+		sess.Where("name=?", query.Name)
+	}
+	sess.Asc("name")
+
+	offset := query.Limit * (query.Page - 1)
+	sess.Limit(query.Limit, offset)
+	sess.Cols("id", "name")
+	if err := sess.Find(&query.Result.UserGroups); err != nil {
+		return err
+	}
+
+	userGroup := m.UserGroup{}
+
+	countSess := x.Table("user_group")
+	if query.Query != "" {
+		countSess.Where("name LIKE ?", queryWithWildcards)
+	}
+	if query.Name != "" {
+		countSess.Where("name=?", query.Name)
+	}
+	count, err := countSess.Count(&userGroup)
+	query.Result.TotalCount = count
+
+	return err
+}
+
+func GetUserGroupById(query *m.GetUserGroupByIdQuery) error {
+	var userGroup m.UserGroup
+	exists, err := x.Id(query.Id).Get(&userGroup)
+	if err != nil {
+		return err
+	}
+
+	if !exists {
+		return m.ErrUserGroupNotFound
+	}
+
+	query.Result = &userGroup
+	return nil
+}
+
+func GetUserGroupsByUser(query *m.GetUserGroupsByUserQuery) error {
+	query.Result = make([]*m.UserGroup, 0)
+
+	sess := x.Table("user_group")
+	sess.Join("INNER", "user_group_member", "user_group.id=user_group_member.user_group_id")
+	sess.Where("user_group_member.user_id=?", query.UserId)
+
+	err := sess.Find(&query.Result)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func AddUserGroupMember(cmd *m.AddUserGroupMemberCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		if res, err := sess.Query("SELECT 1 from user_group_member WHERE user_group_id=? and user_id=?", cmd.UserGroupId, cmd.UserId); err != nil {
+			return err
+		} else if len(res) == 1 {
+			return m.ErrUserGroupMemberAlreadyAdded
+		}
+
+		if res, err := sess.Query("SELECT 1 from user_group WHERE id=?", cmd.UserGroupId); err != nil {
+			return err
+		} else if len(res) != 1 {
+			return m.ErrUserGroupNotFound
+		}
+
+		entity := m.UserGroupMember{
+			OrgId:       cmd.OrgId,
+			UserGroupId: cmd.UserGroupId,
+			UserId:      cmd.UserId,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+		}
+
+		_, err := sess.Insert(&entity)
+		return err
+	})
+}
+
+func RemoveUserGroupMember(cmd *m.RemoveUserGroupMemberCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		var rawSql = "DELETE FROM user_group_member WHERE user_group_id=? and user_id=?"
+		_, err := sess.Exec(rawSql, cmd.UserGroupId, cmd.UserId)
+		if err != nil {
+			return err
+		}
+
+		return err
+	})
+}
+
+func GetUserGroupMembers(query *m.GetUserGroupMembersQuery) error {
+	query.Result = make([]*m.UserGroupMemberDTO, 0)
+	sess := x.Table("user_group_member")
+	sess.Join("INNER", "user", fmt.Sprintf("user_group_member.user_id=%s.id", x.Dialect().Quote("user")))
+	sess.Where("user_group_member.user_group_id=?", query.UserGroupId)
+	sess.Cols("user.org_id", "user_group_member.user_group_id", "user_group_member.user_id", "user.email", "user.login")
+	sess.Asc("user.login", "user.email")
+
+	err := sess.Find(&query.Result)
+	return err
+}

+ 114 - 0
pkg/services/sqlstore/user_group_test.go

@@ -0,0 +1,114 @@
+package sqlstore
+
+import (
+	"fmt"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func TestUserGroupCommandsAndQueries(t *testing.T) {
+
+	Convey("Testing User Group commands & queries", t, func() {
+		InitTestDB(t)
+
+		Convey("Given saved users and two user groups", func() {
+			var userIds []int64
+			for i := 0; i < 5; i++ {
+				userCmd := &m.CreateUserCommand{
+					Email: fmt.Sprint("user", i, "@test.com"),
+					Name:  fmt.Sprint("user", i),
+					Login: fmt.Sprint("loginuser", i),
+				}
+				err := CreateUser(userCmd)
+				So(err, ShouldBeNil)
+				userIds = append(userIds, userCmd.Result.Id)
+			}
+
+			group1 := m.CreateUserGroupCommand{Name: "group1 name"}
+			group2 := m.CreateUserGroupCommand{Name: "group2 name"}
+
+			err := CreateUserGroup(&group1)
+			So(err, ShouldBeNil)
+			err = CreateUserGroup(&group2)
+			So(err, ShouldBeNil)
+
+			Convey("Should be able to create user groups and add users", func() {
+				query := &m.SearchUserGroupsQuery{Name: "group1 name", Page: 1, Limit: 10}
+				err = SearchUserGroups(query)
+				So(err, ShouldBeNil)
+				So(query.Page, ShouldEqual, 1)
+
+				userGroup1 := query.Result.UserGroups[0]
+				So(userGroup1.Name, ShouldEqual, "group1 name")
+
+				err = AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: userGroup1.Id, UserId: userIds[0]})
+				So(err, ShouldBeNil)
+
+				q1 := &m.GetUserGroupMembersQuery{UserGroupId: userGroup1.Id}
+				err = GetUserGroupMembers(q1)
+				So(err, ShouldBeNil)
+				So(q1.Result[0].UserGroupId, ShouldEqual, userGroup1.Id)
+				So(q1.Result[0].Login, ShouldEqual, "loginuser0")
+			})
+
+			Convey("Should be able to search for user groups", func() {
+				query := &m.SearchUserGroupsQuery{Query: "group", Page: 1}
+				err = SearchUserGroups(query)
+				So(err, ShouldBeNil)
+				So(len(query.Result.UserGroups), ShouldEqual, 2)
+				So(query.Result.TotalCount, ShouldEqual, 2)
+
+				query2 := &m.SearchUserGroupsQuery{Query: ""}
+				err = SearchUserGroups(query2)
+				So(err, ShouldBeNil)
+				So(len(query2.Result.UserGroups), ShouldEqual, 2)
+			})
+
+			Convey("Should be able to return all user groups a user is member of", func() {
+				groupId := group2.Result.Id
+				err := AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: groupId, UserId: userIds[0]})
+
+				query := &m.GetUserGroupsByUserQuery{UserId: userIds[0]}
+				err = GetUserGroupsByUser(query)
+				So(err, ShouldBeNil)
+				So(len(query.Result), ShouldEqual, 1)
+				So(query.Result[0].Name, ShouldEqual, "group2 name")
+			})
+
+			Convey("Should be able to remove users from a group", func() {
+				err = RemoveUserGroupMember(&m.RemoveUserGroupMemberCommand{UserGroupId: group1.Result.Id, UserId: userIds[0]})
+				So(err, ShouldBeNil)
+
+				q1 := &m.GetUserGroupMembersQuery{UserGroupId: group1.Result.Id}
+				err = GetUserGroupMembers(q1)
+				So(err, ShouldBeNil)
+				So(len(q1.Result), ShouldEqual, 0)
+			})
+
+			Convey("Should be able to remove a group with users and permissions", func() {
+				groupId := group2.Result.Id
+				err := AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: groupId, UserId: userIds[1]})
+				So(err, ShouldBeNil)
+				err = AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: groupId, UserId: userIds[2]})
+				So(err, ShouldBeNil)
+				err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, UserGroupId: groupId})
+
+				err = DeleteUserGroup(&m.DeleteUserGroupCommand{Id: groupId})
+				So(err, ShouldBeNil)
+
+				query := &m.GetUserGroupByIdQuery{Id: groupId}
+				err = GetUserGroupById(query)
+				So(err, ShouldEqual, m.ErrUserGroupNotFound)
+
+				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
+				err = GetDashboardAclInfoList(permQuery)
+				So(err, ShouldBeNil)
+
+				So(len(permQuery.Result), ShouldEqual, 0)
+			})
+		})
+	})
+}

+ 112 - 58
pkg/services/sqlstore/user_test.go

@@ -6,7 +6,7 @@ import (
 
 	. "github.com/smartystreets/goconvey/convey"
 
-	"github.com/grafana/grafana/pkg/models"
+	m "github.com/grafana/grafana/pkg/models"
 )
 
 func TestUserDataAccess(t *testing.T) {
@@ -14,80 +14,134 @@ func TestUserDataAccess(t *testing.T) {
 	Convey("Testing DB", t, func() {
 		InitTestDB(t)
 
-		var err error
-		for i := 0; i < 5; i++ {
-			err = CreateUser(&models.CreateUserCommand{
-				Email: fmt.Sprint("user", i, "@test.com"),
-				Name:  fmt.Sprint("user", i),
-				Login: fmt.Sprint("loginuser", i),
+		Convey("Given 5 users", func() {
+			var err error
+			var cmd *m.CreateUserCommand
+			users := []m.User{}
+			for i := 0; i < 5; i++ {
+				cmd = &m.CreateUserCommand{
+					Email: fmt.Sprint("user", i, "@test.com"),
+					Name:  fmt.Sprint("user", i),
+					Login: fmt.Sprint("loginuser", i),
+				}
+				err = CreateUser(cmd)
+				So(err, ShouldBeNil)
+				users = append(users, cmd.Result)
+			}
+
+			Convey("Can return the first page of users and a total count", func() {
+				query := m.SearchUsersQuery{Query: "", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
+
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 3)
+				So(query.Result.TotalCount, ShouldEqual, 5)
 			})
-			So(err, ShouldBeNil)
-		}
 
-		Convey("Can return the first page of users and a total count", func() {
-			query := models.SearchUsersQuery{Query: "", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+			Convey("Can return the second page of users and a total count", func() {
+				query := m.SearchUsersQuery{Query: "", Page: 2, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 3)
-			So(query.Result.TotalCount, ShouldEqual, 5)
-		})
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 2)
+				So(query.Result.TotalCount, ShouldEqual, 5)
+			})
 
-		Convey("Can return the second page of users and a total count", func() {
-			query := models.SearchUsersQuery{Query: "", Page: 2, Limit: 3}
-			err = SearchUsers(&query)
+			Convey("Can return list of users matching query on user name", func() {
+				query := m.SearchUsersQuery{Query: "use", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 2)
-			So(query.Result.TotalCount, ShouldEqual, 5)
-		})
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 3)
+				So(query.Result.TotalCount, ShouldEqual, 5)
 
-		Convey("Can return list of users matching query on user name", func() {
-			query := models.SearchUsersQuery{Query: "use", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+				query = m.SearchUsersQuery{Query: "ser1", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 3)
-			So(query.Result.TotalCount, ShouldEqual, 5)
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 1)
+				So(query.Result.TotalCount, ShouldEqual, 1)
 
-			query = models.SearchUsersQuery{Query: "ser1", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+				query = m.SearchUsersQuery{Query: "USER1", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 1)
-			So(query.Result.TotalCount, ShouldEqual, 1)
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 1)
+				So(query.Result.TotalCount, ShouldEqual, 1)
 
-			query = models.SearchUsersQuery{Query: "USER1", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+				query = m.SearchUsersQuery{Query: "idontexist", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 1)
-			So(query.Result.TotalCount, ShouldEqual, 1)
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 0)
+				So(query.Result.TotalCount, ShouldEqual, 0)
+			})
 
-			query = models.SearchUsersQuery{Query: "idontexist", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+			Convey("Can return list of users matching query on email", func() {
+				query := m.SearchUsersQuery{Query: "ser1@test.com", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 0)
-			So(query.Result.TotalCount, ShouldEqual, 0)
-		})
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 1)
+				So(query.Result.TotalCount, ShouldEqual, 1)
+			})
 
-		Convey("Can return list of users matching query on email", func() {
-			query := models.SearchUsersQuery{Query: "ser1@test.com", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+			Convey("Can return list of users matching query on login name", func() {
+				query := m.SearchUsersQuery{Query: "loginuser1", Page: 1, Limit: 3}
+				err = SearchUsers(&query)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 1)
-			So(query.Result.TotalCount, ShouldEqual, 1)
-		})
+				So(err, ShouldBeNil)
+				So(len(query.Result.Users), ShouldEqual, 1)
+				So(query.Result.TotalCount, ShouldEqual, 1)
+			})
+
+			Convey("when a user is an org member and has been assigned permissions", func() {
+				err = AddOrgUser(&m.AddOrgUserCommand{LoginOrEmail: users[0].Login, Role: m.ROLE_VIEWER, OrgId: users[0].OrgId})
+				So(err, ShouldBeNil)
+
+				err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[0].Id, Permission: m.PERMISSION_EDIT})
+				So(err, ShouldBeNil)
 
-		Convey("Can return list of users matching query on login name", func() {
-			query := models.SearchUsersQuery{Query: "loginuser1", Page: 1, Limit: 3}
-			err = SearchUsers(&query)
+				err = SavePreferences(&m.SavePreferencesCommand{UserId: users[0].Id, OrgId: users[0].OrgId, HomeDashboardId: 1, Theme: "dark"})
+				So(err, ShouldBeNil)
 
-			So(err, ShouldBeNil)
-			So(len(query.Result.Users), ShouldEqual, 1)
-			So(query.Result.TotalCount, ShouldEqual, 1)
+				Convey("when the user is deleted", func() {
+					err = DeleteUser(&m.DeleteUserCommand{UserId: users[0].Id})
+					So(err, ShouldBeNil)
+
+					Convey("Should delete connected org users and permissions", func() {
+						query := &m.GetOrgUsersQuery{OrgId: 1}
+						err = GetOrgUsersForTest(query)
+						So(err, ShouldBeNil)
+
+						So(len(query.Result), ShouldEqual, 1)
+
+						permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
+						err = GetDashboardAclInfoList(permQuery)
+						So(err, ShouldBeNil)
+
+						So(len(permQuery.Result), ShouldEqual, 0)
+
+						prefsQuery := &m.GetPreferencesQuery{OrgId: users[0].OrgId, UserId: users[0].Id}
+						err = GetPreferences(prefsQuery)
+						So(err, ShouldBeNil)
+
+						So(prefsQuery.Result.OrgId, ShouldEqual, 0)
+						So(prefsQuery.Result.UserId, ShouldEqual, 0)
+					})
+				})
+			})
 		})
 	})
 }
+
+func GetOrgUsersForTest(query *m.GetOrgUsersQuery) error {
+	query.Result = make([]*m.OrgUserDTO, 0)
+	sess := x.Table("org_user")
+	sess.Join("LEFT ", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user")))
+	sess.Where("org_user.org_id=?", query.OrgId)
+	sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role")
+
+	err := sess.Find(&query.Result)
+	return err
+}

+ 0 - 8
public/app/core/components/grafana_app.ts

@@ -199,14 +199,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv) {
           }
         }
 
-        // hide menus
-        var openMenus = body.find('.navbar-page-btn--open');
-        if (openMenus.length > 0) {
-          if (target.parents('.navbar-page-btn--open').length === 0) {
-            openMenus.removeClass('navbar-page-btn--open');
-          }
-        }
-
         // hide sidemenu
         if (!ignoreSideMenuHide && !contextSrv.pinned && body.find('.sidemenu').length > 0) {
           if (target.parents('.sidemenu').length === 0) {

+ 2 - 9
public/app/core/components/navbar/navbar.html

@@ -3,15 +3,8 @@
 		<span class="navbar-brand-btn-background">
 			<img src="public/img/grafana_icon.svg"></img>
 		</span>
-		<i class="icon-gf icon-gf-grafana_wordmark"></i>
-		<i class="fa fa-caret-down"></i>
-		<i class="fa fa-chevron-left"></i>
 	</a>
 
-  <!-- <a class="navbar&#45;page&#45;btn navbar&#45;page&#45;btn&#45;&#45;search" ng&#45;click="ctrl.showSearch()"> -->
-	<!-- 	<i class="fa fa&#45;search"></i> -->
-	<!-- </a> -->
-
 	<div ng-if="::!ctrl.hasMenu">
 		<a href="{{::ctrl.section.url}}" class="navbar-page-btn">
       <i class="{{::ctrl.section.icon}}" ng-show="::ctrl.section.icon"></i>
@@ -20,7 +13,7 @@
     </a>
 	</div>
 
-  <div class="dropdown navbar-section-wrapper"  ng-if="::ctrl.hasMenu">
+  <div class="dropdown navbar-page-btn-wrapper"  ng-if="::ctrl.hasMenu">
     <a href="{{::ctrl.section.url}}" class="navbar-page-btn" data-toggle="dropdown">
       <i class="{{::ctrl.section.icon}}" ng-show="::ctrl.section.icon"></i>
       <img ng-src="{{::ctrl.section.iconUrl}}" ng-show="::ctrl.section.iconUrl"></i>
@@ -28,7 +21,7 @@
       <i class="fa fa-caret-down"></i>
     </a>
     <ul class="dropdown-menu dropdown-menu--navbar">
-      <li ng-repeat="navItem in ::ctrl.model.menu" ng-class="{active: navItem.active}">
+      <li ng-repeat="navItem in ::ctrl.model.menu">
         <a class="pointer" ng-href="{{::navItem.url}}" ng-click="ctrl.navItemClicked(navItem, $event)">
           <i class="{{::navItem.icon}}" ng-show="::navItem.icon"></i>
           {{::navItem.title}}

+ 14 - 32
public/app/core/components/search/search.html

@@ -4,9 +4,6 @@
 <div class="search-container" ng-if="ctrl.isOpen">
 
 	<div class="search-field-wrapper">
-		<div class="search-field-icon pointer" ng-click="ctrl.closeSearch()">
-			<i class="fa fa-search"></i>
-		</div>
 
 		<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
 						ng-keydown="ctrl.keyDown($event)"
@@ -56,36 +53,21 @@
 		<div class="search-results-container" ng-if="!ctrl.tagsMode">
 			<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
 
-			<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
-				ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
-
-				<span class="search-result-tags">
-					<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag"  class="label label-tag">
-						{{tag}}
-					</span>
-					<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
-				</span>
-
-				<span class="search-result-link">
-					<i class="fa search-result-icon"></i>
-					<span bo-text="row.title"></span>
-				</span>
-			</a>
-		</div>
+    <div ng-repeat="row in ctrl.results">
+      <a class="search-item search-item--{{::row.type}}" ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
+        <span class="search-result-tags">
+          <span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag"  class="label label-tag">
+            {{tag}}
+          </span>
+          <i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
+        </span>
 
-		<div class="search-button-row">
-			<a class="btn btn-secondary" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
-				<i class="fa fa-plus"></i>&nbsp; New Dashboard
-			</a>
-
-			<a class="btn btn-inverse" href="dashboard/new/?editview=import" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
-				<i class="fa fa-upload"></i>&nbsp; Import Dashboard
-			</a>
-
-			<a class="search-button-row-explore-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
-				Find <img src="public/img/icn-dashboard-tiny.svg" width="14" /> dashboards on Grafana.com
-			</a>
-		</div>
+        <span class="search-result-link">
+          <i class="fa search-result-icon"></i>
+          {{::row.title}}
+        </span>
+      </a>
+    </div>
 	</div>
 </div>
 

+ 42 - 8
public/app/core/components/search/search.ts

@@ -30,19 +30,21 @@ export class SearchCtrl {
   closeSearch() {
     this.isOpen = this.ignoreClose;
     this.openCompleted = false;
+    this.contextSrv.isSearching = this.isOpen;
   }
 
   openSearch(evt, payload) {
     if (this.isOpen) {
-      this.isOpen = false;
+      this.closeSearch();
       return;
     }
 
     this.isOpen = true;
+    this.contextSrv.isSearching = true;
     this.giveSearchFocus = 0;
     this.selectedIndex = -1;
     this.results = [];
-    this.query = { query: '', tag: [], starred: false };
+    this.query = { query: '', tag: [], starred: false, mode: 'tree' };
     this.currentSearchId = 0;
     this.ignoreClose = true;
 
@@ -104,17 +106,49 @@ export class SearchCtrl {
     this.currentSearchId = this.currentSearchId + 1;
     var localSearchId = this.currentSearchId;
 
-    return this.backendSrv.search(this.query).then((results) => {
+    return this.backendSrv.search(this.query).then(results => {
       if (localSearchId < this.currentSearchId) { return; }
 
-      this.results = _.map(results, function(dash) {
-        dash.url = 'dashboard/' + dash.uri;
-        return dash;
+      let byId = _.groupBy(results, 'id');
+      let byFolderId = _.groupBy(results, 'folderId');
+      let finalList = [];
+
+      // add missing parent folders
+      _.each(results, (hit, index) => {
+        if (hit.folderId && !byId[hit.folderId]) {
+          const folder = {
+            id: hit.folderId,
+            uri: `db/${hit.folderSlug}`,
+            title: hit.folderTitle,
+            type: 'dash-folder'
+          };
+          byId[hit.folderId] = folder;
+          results.splice(index, 0, folder);
+        }
       });
 
-      if (this.queryHasNoFilters()) {
-        this.results.unshift({ title: 'Home', url: config.appSubUrl + '/', type: 'dash-home' });
+      // group by folder
+      for (let hit of results) {
+        if (hit.folderId) {
+          hit.type = "dash-child";
+        } else {
+          finalList.push(hit);
+        }
+
+        hit.url = 'dashboard/' + hit.uri;
+
+        if (hit.type === 'dash-folder') {
+          if (!byFolderId[hit.id]) {
+            continue;
+          }
+
+          for (let child of byFolderId[hit.id]) {
+            finalList.push(child);
+          }
+        }
       }
+
+      this.results = finalList;
     });
   }
 

+ 41 - 41
public/app/core/components/sidemenu/sidemenu.html

@@ -1,50 +1,22 @@
 <ul class="sidemenu">
 
-	<li class="sidemenu-org-section" ng-if="::ctrl.isSignedIn" class="dropdown">
-		<a class="sidemenu-org" href="profile">
-			<div class="sidemenu-org-avatar">
-				<img ng-src="{{::ctrl.user.gravatarUrl}}">
-				<span class="sidemenu-org-avatar--missing">
-					<i class="fa fa-fw fa-user"></i>
-				</span>
-			</div>
-			<div class="sidemenu-org-details">
-				<span class="sidemenu-org-user sidemenu-item-text">{{::ctrl.user.name}}</span>
-				<span class="sidemenu-org-name sidemenu-item-text">{{::ctrl.user.orgName}}</span>
-			</div>
+	<li>
+		<a class="sidemenu-item" ng-click="ctrl.search()">
+			<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-search"></i></span>
 		</a>
-		<i class="fa fa-caret-right"></i>
-		<ul class="dropdown-menu" role="menu">
-			<li ng-repeat="menuItem in ctrl.orgMenu" ng-class="::menuItem.cssClass">
-				<span ng-show="::menuItem.section">{{::menuItem.section}}</span>
-				<a href="{{::menuItem.url}}" ng-show="::menuItem.url" target="{{::menuItem.target}}">
-					<i class="{{::menuItem.icon}}" ng-show="::menuItem.icon"></i>
-					{{::menuItem.text}}
-				</a>
-			</li>
-            <li ng-show="ctrl.orgs.length > ctrl.maxShownOrgs" style="margin-left: 10px;width: 90%">
-                <span class="sidemenu-item-text">Max shown : {{::ctrl.maxShownOrgs}}</span>
-                <input ng-model="::ctrl.orgFilter" style="padding-left: 5px" type="text" ng-change="::ctrl.loadOrgsItems();" class="gf-input-small width-12" placeholder="Filter">
-            </li>
-            <li ng-repeat="orgItem in ctrl.orgItems" ng-class="::orgItem.cssClass">
-				<a href="{{::orgItem.url}}" ng-show="::orgItem.url" target="{{::orgItem.target}}">
-					<i class="{{::orgItem.icon}}" ng-show="::orgItem.icon"></i>
-					{{::orgItem.text}}
-				</a>
-			</li>
-		</ul>
 	</li>
 
 	<li ng-repeat="item in ::ctrl.mainLinks" class="dropdown">
-		<a href="{{::item.url}}" class="sidemenu-item sidemenu-main-link" target="{{::item.target}}">
+		<a href="{{::item.url}}" class="sidemenu-item" target="{{::item.target}}">
 			<span class="icon-circle sidemenu-icon">
 				<i class="{{::item.icon}}" ng-show="::item.icon"></i>
 				<img ng-src="{{::item.img}}" ng-show="::item.img">
 			</span>
-			<span class="sidemenu-item-text">{{::item.text}}</span>
-			<span class="fa fa-caret-right" ng-if="::item.children"></span>
 		</a>
-		<ul class="dropdown-menu" role="menu" ng-if="::item.children">
+		<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
+			<li class="side-menu-header">
+				<span class="sidemenu-item-text">{{::item.text}}</span>
+			</li>
 			<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
 				<a href="{{::child.url}}">
 					<i class="{{::child.icon}}" ng-show="::child.icon"></i>
@@ -55,17 +27,45 @@
 	</li>
 
 	<li ng-show="::!ctrl.isSignedIn">
-    <a href="{{ctrl.loginUrl}}" class="sidemenu-item" target="_self">
+		<a href="{{ctrl.loginUrl}}" class="sidemenu-item" target="_self">
 			<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-sign-in"></i></span>
 			<span class="sidemenu-item-text">Sign in</span>
 		</a>
 	</li>
 
-	<li>
-		<a class="sidemenu-item" target="_self" ng-hide="ctrl.contextSrv.pinned" ng-click="ctrl.contextSrv.setPinnedState(true)">
-			<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-thumb-tack"></i></span>
-			<span class="sidemenu-item-text">Pin</span>
+	<li class="sidemenu-org-section" ng-if="::ctrl.isSignedIn" class="dropdown">
+		<a class="sidemenu-item" href="profile">
+			<span class="icon-circle sidemenu-icon sidemenu-org-avatar">
+				<img ng-src="{{::ctrl.user.gravatarUrl}}">
+				<span class="sidemenu-org-avatar--missing">
+					<i class="fa fa-fw fa-user"></i>
+				</span>
+			</div>
 		</a>
+		<ul class="dropdown-menu dropdown-menu--sidemenu dropup" role="menu">
+			<li class="side-menu-header">
+				<span class="sidemenu-org-user sidemenu-item-text">{{::ctrl.user.name}}</span>
+				<span class="sidemenu-org-name sidemenu-item-text">{{::ctrl.user.orgName}}</span>
+			</li>
+			<li ng-repeat="menuItem in ctrl.orgMenu" ng-class="::menuItem.cssClass">
+				<span ng-show="::menuItem.section">{{::menuItem.section}}</span>
+				<a href="{{::menuItem.url}}" ng-show="::menuItem.url" target="{{::menuItem.target}}">
+					<i class="{{::menuItem.icon}}" ng-show="::menuItem.icon"></i>
+					{{::menuItem.text}}
+				</a>
+			</li>
+			<li ng-show="ctrl.orgs.length > ctrl.maxShownOrgs" style="margin-left: 10px;width: 90%">
+				<span class="sidemenu-item-text">Max shown : {{::ctrl.maxShownOrgs}}</span>
+				<input ng-model="::ctrl.orgFilter" style="padding-left: 5px" type="text" ng-change="::ctrl.loadOrgsItems();" class="gf-input-small width-12" placeholder="Filter">
+			</li>
+			<li ng-repeat="orgItem in ctrl.orgItems" ng-class="::orgItem.cssClass">
+				<a href="{{::orgItem.url}}" ng-show="::orgItem.url" target="{{::orgItem.target}}">
+					<i class="{{::orgItem.icon}}" ng-show="::orgItem.icon"></i>
+					{{::orgItem.text}}
+				</a>
+			</li>
+		</ul>
 	</li>
+
 </ul>
 

+ 9 - 1
public/app/core/components/sidemenu/sidemenu.ts

@@ -19,7 +19,7 @@ export class SideMenuCtrl {
   maxShownOrgs: number;
 
   /** @ngInject */
-  constructor(private $scope, private $location, private contextSrv, private backendSrv, private $element) {
+  constructor(private $scope, private $rootScope, private $location, private contextSrv, private backendSrv, private $element) {
     this.isSignedIn = contextSrv.isSignedIn;
     this.user = contextSrv.user;
     this.appSubUrl = config.appSubUrl;
@@ -44,6 +44,10 @@ export class SideMenuCtrl {
    return config.appSubUrl + url;
  }
 
+ search() {
+   this.$rootScope.appEvent('show-dash-search');
+ }
+
  openUserDropdown() {
    this.orgMenu = [
      {section: 'You', cssClass: 'dropdown-menu-title'},
@@ -64,6 +68,10 @@ export class SideMenuCtrl {
        text: "Users",
        url: this.getUrl("/org/users")
      });
+     this.orgMenu.push({
+       text: "User Groups",
+       url: this.getUrl("/org/user-groups")
+     });
      this.orgMenu.push({
        text: "API Keys",
        url: this.getUrl("/org/apikeys")

+ 60 - 0
public/app/core/components/user_group_picker.ts

@@ -0,0 +1,60 @@
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+const template = `
+<div class="dropdown">
+  <gf-form-dropdown model="ctrl.group"
+                    get-options="ctrl.debouncedSearchGroups($query)"
+                    css-class="gf-size-auto"
+                    on-change="ctrl.onChange($option)"
+  </gf-form-dropdown>
+</div>
+`;
+export class UserGroupPickerCtrl {
+  group: any;
+  userGroupPicked: any;
+  debouncedSearchGroups: any;
+
+  /** @ngInject */
+  constructor(private backendSrv, private $scope, $sce, private uiSegmentSrv) {
+    this.debouncedSearchGroups = _.debounce(this.searchGroups, 500, {'leading': true, 'trailing': false});
+    this.reset();
+  }
+
+  reset() {
+    this.group = {text: 'Choose', value: null};
+  }
+
+  searchGroups(query: string) {
+    return Promise.resolve(this.backendSrv.get('/api/user-groups/search?perpage=10&page=1&query=' + query).then(result => {
+      return _.map(result.userGroups, ug => {
+        return {text: ug.name, value: ug};
+      });
+    }));
+  }
+
+  onChange(option) {
+    this.userGroupPicked({$group: option.value});
+  }
+}
+
+export function userGroupPicker() {
+  return {
+    restrict: 'E',
+    template: template,
+    controller: UserGroupPickerCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      userGroupPicked: '&',
+    },
+    link: function(scope, elem, attrs, ctrl) {
+      scope.$on("user-group-picker-reset", () => {
+        ctrl.reset();
+      });
+    }
+  };
+}
+
+coreModule.directive('userGroupPicker', userGroupPicker);

+ 67 - 0
public/app/core/components/user_picker.ts

@@ -0,0 +1,67 @@
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+const template = `
+<div class="dropdown">
+  <gf-form-dropdown model="ctrl.user"
+                    get-options="ctrl.debouncedSearchUsers($query)"
+                    css-class="gf-size-auto"
+                    on-change="ctrl.onChange($option)"
+  </gf-form-dropdown>
+</div>
+`;
+export class UserPickerCtrl {
+  user: any;
+  debouncedSearchUsers: any;
+  userPicked: any;
+
+  /** @ngInject */
+  constructor(private backendSrv, private $scope, $sce) {
+    this.reset();
+    this.debouncedSearchUsers = _.debounce(this.searchUsers, 500, {'leading': true, 'trailing': false});
+  }
+
+  searchUsers(query: string) {
+    return Promise.resolve(this.backendSrv.get('/api/users/search?perpage=10&page=1&query=' + query).then(result => {
+      return _.map(result.users, user => {
+        return {text: user.login + ' -  ' + user.email, value: user};
+      });
+    }));
+  }
+
+  onChange(option) {
+    this.userPicked({$user: option.value});
+  }
+
+  reset() {
+    this.user = {text: 'Choose', value: null};
+  }
+}
+
+export interface User {
+  id: number;
+  name: string;
+  login: string;
+  email: string;
+}
+
+export function userPicker() {
+  return {
+    restrict: 'E',
+    template: template,
+    controller: UserPickerCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      userPicked: '&',
+    },
+    link: function(scope, elem, attrs, ctrl) {
+      scope.$on("user-picker-reset", () => {
+        ctrl.reset();
+      });
+    }
+  };
+}
+
+coreModule.directive('userPicker', userPicker);

+ 4 - 1
public/app/core/core.ts

@@ -49,7 +49,8 @@ import {helpModal} from './components/help/help';
 import {collapseBox} from './components/collapse_box';
 import {JsonExplorer} from './components/json_explorer/json_explorer';
 import {NavModelSrv, NavModel} from './nav_model_srv';
-
+import {userPicker} from './components/user_picker';
+import {userGroupPicker} from './components/user_group_picker';
 
 export {
   arrayJoin,
@@ -78,4 +79,6 @@ export {
   JsonExplorer,
   NavModelSrv,
   NavModel,
+  userPicker,
+  userGroupPicker,
 };

+ 21 - 8
public/app/core/directives/dash_edit_link.js

@@ -2,8 +2,9 @@ define([
   'jquery',
   'angular',
   '../core_module',
+  'lodash',
 ],
-function ($, angular, coreModule) {
+function ($, angular, coreModule, _) {
   'use strict';
 
   var editViewMap = {
@@ -12,7 +13,13 @@ function ($, angular, coreModule) {
     'templating':  { src: 'public/app/features/templating/partials/editor.html'},
     'history':     { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
     'timepicker':  { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
-    'import':      { html: '<dash-import></dash-import>' }
+    'import':      { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
+    'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
+    'new-folder':  {
+      isModal: true,
+      html: '<folder-modal dismiss="dismiss()"></folder-modal>',
+      modalClass: 'modal--narrow'
+    }
   };
 
   coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
@@ -20,6 +27,7 @@ function ($, angular, coreModule) {
       restrict: 'A',
       link: function(scope, elem) {
         var editorScope;
+        var modalScope;
         var lastEditView;
 
         function hideEditorPane(hideToShowOtherView) {
@@ -30,8 +38,7 @@ function ($, angular, coreModule) {
 
         function showEditorPane(evt, options) {
           if (options.editview) {
-            options.src = editViewMap[options.editview].src;
-            options.html = editViewMap[options.editview].html;
+            _.defaults(options, editViewMap[options.editview]);
           }
 
           if (lastEditView && lastEditView === options.editview) {
@@ -45,6 +52,11 @@ function ($, angular, coreModule) {
           editorScope = options.scope ? options.scope.$new() : scope.$new();
 
           editorScope.dismiss = function(hideToShowOtherView) {
+            if (modalScope) {
+              modalScope.dismiss();
+              modalScope = null;
+            }
+
             editorScope.$destroy();
             lastEditView = null;
             editorScope = null;
@@ -73,16 +85,17 @@ function ($, angular, coreModule) {
             }
           };
 
-          if (options.editview === 'import') {
-            var modalScope = $rootScope.$new();
+          if (options.isModal) {
+            modalScope = $rootScope.$new();
             modalScope.$on("$destroy", function() {
               editorScope.dismiss();
             });
 
             $rootScope.appEvent('show-modal', {
-              templateHtml: '<dash-import></dash-import>',
+              templateHtml: options.html,
               scope: modalScope,
-              backdrop: 'static'
+              backdrop: 'static',
+              modalClass: options.modalClass,
             });
 
             return;

+ 11 - 2
public/app/core/nav_model_srv.ts

@@ -96,6 +96,7 @@ export class NavModelSrv {
         {title: 'Preferences', active: subPage === 0, url: 'org', icon: 'fa fa-fw fa-cog'},
         {title: 'Org Users', active: subPage === 1, url: 'org/users', icon: 'fa fa-fw fa-users'},
         {title: 'API Keys', active: subPage === 2, url: 'org/apikeys', icon: 'fa fa-fw fa-key'},
+        {title: 'Org User Groups', active: subPage === 3, url: 'org/user-groups', icon: 'fa fa-fw fa-users'},
       ]
     };
   }
@@ -167,6 +168,14 @@ export class NavModelSrv {
         clickHandler: () => dashNavCtrl.openEditView('annotations')
       });
 
+      if (dashboard.meta.canAdmin) {
+        menu.push({
+          title: 'Permissions...',
+          icon: 'fa fa-fw fa-lock',
+          clickHandler: () => dashNavCtrl.openEditView('permissions')
+        });
+      }
+
       if (!dashboard.meta.isHome) {
         menu.push({
           title: 'Version history',
@@ -196,9 +205,9 @@ export class NavModelSrv {
       clickHandler: () => dashNavCtrl.showHelpModal()
     });
 
-    if (this.contextSrv.isEditor) {
+    if (this.contextSrv.isEditor && !dashboard.meta.isFolder) {
       menu.push({
-        title: 'Save As ...',
+        title: 'Save As...',
         icon: 'fa fa-fw fa-save',
         clickHandler: () => dashNavCtrl.saveDashboardAs()
       });

+ 12 - 0
public/app/core/routes/routes.ts

@@ -83,6 +83,18 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     controller : 'OrgApiKeysCtrl',
     resolve: loadOrgBundle,
   })
+  .when('/org/user-groups', {
+    templateUrl: 'public/app/features/org/partials/user_groups.html',
+    controller : 'UserGroupsCtrl',
+    controllerAs: 'ctrl',
+    resolve: loadOrgBundle,
+  })
+  .when('/org/user-groups/edit/:id', {
+    templateUrl: 'public/app/features/org/partials/user_group_details.html',
+    controller : 'UserGroupDetailsCtrl',
+    controllerAs: 'ctrl',
+    resolve: loadOrgBundle,
+  })
   .when('/profile', {
     templateUrl: 'public/app/features/org/partials/profile.html',
     controller : 'ProfileCtrl',

+ 54 - 0
public/app/core/services/backend_srv.ts

@@ -211,10 +211,64 @@ export class BackendSrv {
 
     return this.post('/api/dashboards/db/', {
       dashboard: dash,
+      folderId: dash.folderId,
       overwrite: options.overwrite === true,
       message: options.message || '',
     });
   }
+
+  createDashboardFolder(name) {
+    const dash = {
+      title: name,
+      editable: true,
+      hideControls: true,
+      rows: [
+        {
+          panels: [
+            {
+              folderId: 0,
+              headings: false,
+              limit: 1000,
+              links: [],
+              query: '',
+              recent: false,
+              search: true,
+              span: 4,
+              starred: false,
+              tags: [],
+              title: 'Dashboards in this folder',
+              type: 'dashlist'
+            },
+            {
+              onlyAlertsOnDashboard: true,
+              span: 4,
+              title: 'Alerts in this folder',
+              type: 'alertlist'
+            },
+            {
+              span: 4,
+              title: 'Permissions for this folder',
+              type: 'permissionlist',
+              folderId: 0
+            }
+          ],
+          showTitle: true,
+          title: name,
+          titleSize: 'h1'
+        }
+      ]
+    };
+
+    return this.post('/api/dashboards/db/', {dashboard: dash, isFolder: true, overwrite: false})
+    .then(res => {
+      return this.getDashboard('db', res.slug);
+    })
+    .then(res => {
+      res.dashboard.rows[0].panels[0].folderId = res.dashboard.id;
+      res.dashboard.rows[0].panels[2].folderId = res.dashboard.id;
+      return this.saveDashboard(res.dashboard, {overwrite: false});
+    });
+  }
 }
 
 coreModule.service('backendSrv', BackendSrv);

+ 1 - 3
public/app/core/services/context_srv.ts

@@ -64,9 +64,7 @@ export class ContextSrv {
 
   toggleSideMenu() {
     this.sidemenu = !this.sidemenu;
-    if (!this.sidemenu) {
-      this.setPinnedState(false);
-    }
+    this.setPinnedState(this.sidemenu);
   }
 }
 

+ 0 - 5
public/app/features/alerting/partials/alert_list.html

@@ -7,11 +7,6 @@
 			<i class="fa fa-info-circle"></i>
 			How to add an alert
 		</a>
-
-    <a class="btn btn-inverse" href="alerting/notifications" >
-			<i class="fa fa-cog"></i>
-			Configure notifications
-		</a>
 	</div>
 
   <div class="gf-form-group">

+ 126 - 0
public/app/features/dashboard/acl/acl.html

@@ -0,0 +1,126 @@
+<div class="modal-body">
+  <div class="modal-header">
+    <h2 class="modal-header-title">
+      <i class="fa fa-lock"></i>
+      <span class="p-l-1">Permissions</span>
+    </h2>
+
+    <a class="modal-header-close" ng-click="ctrl.dismiss();">
+      <i class="fa fa-remove"></i>
+    </a>
+  </div>
+
+  <div class="modal-content">
+    <table class="filter-table gf-form-group">
+      <tr ng-repeat="acl in ctrl.items" ng-class="{'gf-form-disabled': acl.inherited}">
+        <td style="width: 100%;">
+          <i class="{{acl.icon}}"></i>
+          <span ng-bind-html="acl.nameHtml"></span>
+        </td>
+        <td>
+          <em class="muted no-wrap" ng-show="acl.inherited">Inherited from folder</em>
+        </td>
+        <td class="query-keyword">Can</td>
+        <td>
+          <div class="gf-form-select-wrapper">
+            <select class="gf-form-input gf-size-auto" ng-model="acl.permission" ng-options="p.value as p.text for p in ctrl.permissionOptions" ng-change="ctrl.permissionChanged(acl)" ng-disabled="acl.inherited"></select>
+          </div>
+        </td>
+        <td>
+          <a class="btn btn-inverse btn-small" ng-click="ctrl.removeItem($index)" ng-hide="acl.inherited">
+            <i class="fa fa-remove"></i>
+          </a>
+        </td>
+      </tr>
+      <tr ng-show="ctrl.aclItems.length === 0">
+        <td colspan="4">
+          <em>No permissions. Will only be accessible by admins.</em>
+        </td>
+      </tr>
+    </table>
+
+    <div class="gf-form-inline">
+      <form name="addPermission" class="gf-form-group">
+        <h6 class="muted">Add Permission For</h6>
+        <div class="gf-form-inline">
+          <div class="gf-form">
+            <div class="gf-form-select-wrapper">
+              <select class="gf-form-input gf-size-auto" ng-model="ctrl.newType" ng-options="p.value as p.text for p in ctrl.aclTypes"  ng-change="ctrl.typeChanged()"></select>
+            </div>
+          </div>
+          <div class="gf-form" ng-show="ctrl.newType === 'User'">
+            <user-picker user-picked="ctrl.userPicked($user)"></user-picker>
+          </div>
+          <div class="gf-form" ng-show="ctrl.newType === 'Group'">
+            <user-group-picker user-group-picked="ctrl.groupPicked($group)"></user-group-picker>
+          </div>
+        </div>
+      </form>
+      <div class="gf-form width-17">
+        <span ng-if="ctrl.error" class="text-error p-l-1">
+          <i class="fa fa-warning"></i>
+          {{ctrl.error}}
+        </span>
+      </div>
+    </div>
+
+    <div class="gf-form-button-row text-center">
+      <button type="button" class="btn btn-danger" ng-disabled="!ctrl.canUpdate" ng-click="ctrl.update()">
+        Update Permissions
+      </button>
+      <a class="btn-text" ng-click="ctrl.dismiss();">Close</a>
+    </div>
+  </div>
+</div>
+
+  <!-- <br> -->
+  <!-- <br> -->
+  <!-- <br> -->
+  <!--  -->
+  <!-- <div class="permissionlist"> -->
+  <!--   <div class="permissionlist__section"> -->
+  <!--     <div class="permissionlist__section&#45;header"> -->
+  <!--       <h6>Permissions</h6> -->
+  <!--     </div> -->
+  <!--     <table class="filter&#45;table form&#45;inline"> -->
+  <!--       <thead> -->
+  <!--         <tr> -->
+  <!--           <th style="width: 50px;"></th> -->
+  <!--           <th>Name</th> -->
+  <!--           <th style="width: 220px;">Permission</th> -->
+  <!--           <th style="width: 120px"></th> -->
+  <!--         </tr> -->
+  <!--       </thead> -->
+  <!--       <tbody> -->
+  <!--         <tr ng&#45;repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
+  <!--           <td><i class="fa fa&#45;fw fa&#45;user"></i></td> -->
+  <!--           <td>{{permission.userLogin}}</td> -->
+  <!--           <td class="text&#45;right"> -->
+  <!--             <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
+  <!--               <i class="fa fa&#45;remove"></i> -->
+  <!--             </a> -->
+  <!--           </td> -->
+  <!--         </tr> -->
+  <!--         <tr ng&#45;repeat="permission in ctrl.userGroupPermissions" class="permissionlist__item"> -->
+  <!--           <td><i class="fa fa&#45;fw fa&#45;users"></i></td> -->
+  <!--           <td>{{permission.userGroup}}</td> -->
+  <!--           <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="permission.permissions" ng&#45;options="p.value as p.text for p in ctrl.permissionTypeOptions" ng&#45;change="ctrl.updatePermission(permission)"></select></td> -->
+  <!--           <td class="text&#45;right"> -->
+  <!--             <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
+  <!--               <i class="fa fa&#45;remove"></i> -->
+  <!--             </a> -->
+  <!--           </td> -->
+  <!--         </tr> -->
+  <!--         <tr ng&#45;repeat="role in ctrl.roles" class="permissionlist__item"> -->
+  <!--           <td></td> -->
+  <!--           <td>{{role.name}}</td> -->
+  <!--           <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="role.permissions" ng&#45;options="p.value as p.text for p in ctrl.roleOptions" ng&#45;change="ctrl.updatePermission(role)"></select></td> -->
+  <!--           <td class="text&#45;right"> -->
+  <!--  -->
+  <!--           </td> -->
+  <!--         </tr> -->
+  <!--       </tbody> -->
+  <!--     </table> -->
+  <!--   </div> -->
+  <!--   </div> -->
+  <!-- </div> -->

+ 204 - 0
public/app/features/dashboard/acl/acl.ts

@@ -0,0 +1,204 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+export class AclCtrl {
+  dashboard: any;
+  items: DashboardAcl[];
+  permissionOptions = [
+    {value: 1, text: 'View'},
+    {value: 2, text: 'Edit'},
+    {value: 4, text: 'Admin'}
+  ];
+  aclTypes = [
+    {value: 'Group', text: 'User Group'},
+    {value: 'User',  text: 'User'},
+    {value: 'Viewer', text: 'Everyone With Viewer Role'},
+    {value: 'Editor', text: 'Everyone With Editor Role'}
+  ];
+
+  dismiss: () => void;
+  newType: string;
+  canUpdate: boolean;
+  error: string;
+  readonly duplicateError = 'This permission exists already.';
+
+  /** @ngInject */
+  constructor(private backendSrv, private dashboardSrv, private $sce, private $scope) {
+    this.items = [];
+    this.resetNewType();
+    this.dashboard = dashboardSrv.getCurrent();
+    this.get(this.dashboard.id);
+  }
+
+  resetNewType() {
+    this.newType = 'Group';
+  }
+
+  get(dashboardId: number) {
+    return this.backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`)
+      .then(result => {
+        this.items = _.map(result, this.prepareViewModel.bind(this));
+        this.sortItems();
+      });
+  }
+
+  sortItems() {
+    this.items = _.orderBy(this.items, ['sortRank', 'sortName'], ['desc', 'asc']);
+  }
+
+  prepareViewModel(item: DashboardAcl): DashboardAcl {
+    item.inherited = !this.dashboard.meta.isFolder && this.dashboard.id !== item.dashboardId;
+    item.sortRank = 0;
+
+    if (item.userId > 0) {
+      item.icon = "fa fa-fw fa-user";
+      item.nameHtml = this.$sce.trustAsHtml(item.userLogin);
+      item.sortName = item.userLogin;
+      item.sortRank = 10;
+    } else if (item.userGroupId > 0) {
+      item.icon = "fa fa-fw fa-users";
+      item.nameHtml = this.$sce.trustAsHtml(item.userGroup);
+      item.sortName = item.userGroup;
+      item.sortRank = 20;
+    } else if (item.role) {
+      item.icon = "fa fa-fw fa-street-view";
+      item.nameHtml = this.$sce.trustAsHtml(`Everyone with <span class="query-keyword">${item.role}</span> Role`);
+      item.sortName = item.role;
+      item.sortRank = 30;
+      if (item.role === 'Viewer') {
+        item.sortRank += 1;
+      }
+    }
+
+    if (item.inherited) {
+      item.sortRank += 100;
+    }
+
+    return item;
+  }
+
+  update() {
+    var updated = [];
+    for (let item of this.items) {
+      if (item.inherited) {
+        continue;
+      }
+      updated.push({
+        id: item.id,
+        userId: item.userId,
+        userGroupId: item.userGroupId,
+        role: item.role,
+        permission: item.permission,
+      });
+    }
+
+    return this.backendSrv.post(`/api/dashboards/id/${this.dashboard.id}/acl`, { items: updated }).then(() => {
+      return this.dismiss();
+    });
+  }
+
+  typeChanged() {
+    if (this.newType === 'Viewer' || this.newType === 'Editor') {
+      this.addNewItem({permission: 1, role: this.newType});
+      this.canUpdate = true;
+      this.resetNewType();
+    }
+  }
+
+  permissionChanged() {
+    this.canUpdate = true;
+  }
+
+  addNewItem(item) {
+    if (!this.isValid(item)) {
+      return;
+    }
+    this.error = '';
+
+    item.dashboardId = this.dashboard.id;
+
+    this.items.push(this.prepareViewModel(item));
+    this.sortItems();
+
+    this.canUpdate = true;
+  }
+
+  isValid(item) {
+    const dupe = _.find(this.items, (it) => { return this.isDuplicate(it, item); });
+
+    if (dupe) {
+      this.error = this.duplicateError;
+      return false;
+    }
+
+    return true;
+  }
+
+  isDuplicate(origItem, newItem) {
+    if (origItem.inherited) {
+      return false;
+    }
+
+    return (origItem.role && newItem.role && origItem.role === newItem.role) ||
+    (origItem.userId && newItem.userId && origItem.userId === newItem.userId) ||
+    (origItem.userGroupId && newItem.userGroupId && origItem.userGroupId === newItem.userGroupId);
+  }
+
+  userPicked(user) {
+    this.addNewItem({userId: user.id, userLogin: user.login, permission: 1,});
+    this.$scope.$broadcast('user-picker-reset');
+  }
+
+  groupPicked(group) {
+    this.addNewItem({userGroupId: group.id, userGroup: group.name, permission: 1});
+    this.$scope.$broadcast('user-group-picker-reset');
+  }
+
+  removeItem(index) {
+    this.items.splice(index, 1);
+    this.canUpdate = true;
+  }
+}
+
+export function dashAclModal() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/features/dashboard/acl/acl.html',
+    controller: AclCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      dismiss: "&"
+    }
+  };
+}
+
+export interface FormModel {
+  dashboardId: number;
+  userId?: number;
+  userGroupId?: number;
+  PermissionType: number;
+}
+
+export interface DashboardAcl {
+  id?: number;
+  dashboardId?: number;
+  userId?: number;
+  userLogin?: string;
+  userEmail?: string;
+  userGroupId?: number;
+  userGroup?: string;
+  permission?: number;
+  permissionName?: string;
+  role?: string;
+  icon?: string;
+  nameHtml?: string;
+  inherited?: boolean;
+  sortName?: string;
+  sortRank?: number;
+}
+
+coreModule.directive('dashAclModal', dashAclModal);

+ 180 - 0
public/app/features/dashboard/acl/specs/acl_specs.ts

@@ -0,0 +1,180 @@
+import {describe, beforeEach, it, expect, sinon, angularMocks} from 'test/lib/common';
+import {AclCtrl} from '../acl';
+
+describe('AclCtrl', () => {
+  const ctx: any = {};
+  const backendSrv = {
+    get: sinon.stub().returns(Promise.resolve([])),
+    post: sinon.stub().returns(Promise.resolve([]))
+  };
+
+  const dashboardSrv = {
+    getCurrent: sinon.stub().returns({id: 1, meta: { isFolder: false }})
+  };
+
+  beforeEach(angularMocks.module('grafana.core'));
+  beforeEach(angularMocks.module('grafana.controllers'));
+
+  beforeEach(angularMocks.inject(($rootScope, $controller, $q, $compile) => {
+    ctx.$q = $q;
+    ctx.scope = $rootScope.$new();
+    AclCtrl.prototype.dashboard = {dashboard: {id: 1}};
+    ctx.ctrl = $controller(AclCtrl, {
+      $scope: ctx.scope,
+      backendSrv: backendSrv,
+      dashboardSrv: dashboardSrv
+    }, {
+      dismiss: () => { return; }
+    });
+  }));
+
+  describe('when permissions are added', () => {
+    beforeEach(() => {
+      backendSrv.get.reset();
+      backendSrv.post.reset();
+
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+
+      ctx.ctrl.userPicked(userItem);
+
+      const userGroupItem = {
+        id: 2,
+        name: 'ug1',
+      };
+
+      ctx.ctrl.groupPicked(userGroupItem);
+
+      ctx.ctrl.newType = 'Editor';
+      ctx.ctrl.typeChanged();
+
+      ctx.ctrl.newType = 'Viewer';
+      ctx.ctrl.typeChanged();
+    });
+
+     it('should sort the result by role, user group and user', () => {
+        expect(ctx.ctrl.items[0].role).to.eql('Viewer');
+        expect(ctx.ctrl.items[1].role).to.eql('Editor');
+        expect(ctx.ctrl.items[2].userGroupId).to.eql(2);
+        expect(ctx.ctrl.items[3].userId).to.eql(2);
+      });
+
+    it('should save permissions to db', (done) => {
+      ctx.ctrl.update().then(() => {
+        done();
+      });
+
+      expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/dashboards/id/1/acl');
+      expect(backendSrv.post.getCall(0).args[1].items[0].role).to.eql('Viewer');
+      expect(backendSrv.post.getCall(0).args[1].items[0].permission).to.eql(1);
+      expect(backendSrv.post.getCall(0).args[1].items[1].role).to.eql('Editor');
+      expect(backendSrv.post.getCall(0).args[1].items[1].permission).to.eql(1);
+      expect(backendSrv.post.getCall(0).args[1].items[2].userGroupId).to.eql(2);
+      expect(backendSrv.post.getCall(0).args[1].items[2].permission).to.eql(1);
+      expect(backendSrv.post.getCall(0).args[1].items[3].userId).to.eql(2);
+      expect(backendSrv.post.getCall(0).args[1].items[3].permission).to.eql(1);
+    });
+  });
+
+  describe('when duplicate role permissions are added', () => {
+    beforeEach(() => {
+      backendSrv.get.reset();
+      backendSrv.post.reset();
+      ctx.ctrl.items = [];
+
+      ctx.ctrl.newType = 'Editor';
+      ctx.ctrl.typeChanged();
+
+      ctx.ctrl.newType = 'Editor';
+      ctx.ctrl.typeChanged();
+    });
+
+    it('should throw a validation error', () => {
+      expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
+    });
+
+    it('should not add the duplicate permission', () => {
+      expect(ctx.ctrl.items.length).to.eql(1);
+    });
+  });
+
+  describe('when duplicate user permissions are added', () => {
+    beforeEach(() => {
+      backendSrv.get.reset();
+      backendSrv.post.reset();
+      ctx.ctrl.items = [];
+
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+
+      ctx.ctrl.userPicked(userItem);
+      ctx.ctrl.userPicked(userItem);
+    });
+
+    it('should throw a validation error', () => {
+      expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
+    });
+
+    it('should not add the duplicate permission', () => {
+      expect(ctx.ctrl.items.length).to.eql(1);
+    });
+  });
+
+  describe('when duplicate user group permissions are added', () => {
+    beforeEach(() => {
+      backendSrv.get.reset();
+      backendSrv.post.reset();
+      ctx.ctrl.items = [];
+
+      const userGroupItem = {
+        id: 2,
+        name: 'ug1',
+      };
+
+      ctx.ctrl.groupPicked(userGroupItem);
+      ctx.ctrl.groupPicked(userGroupItem);
+    });
+
+    it('should throw a validation error', () => {
+      expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
+    });
+
+    it('should not add the duplicate permission', () => {
+      expect(ctx.ctrl.items.length).to.eql(1);
+    });
+  });
+
+  describe('when one inherited and one not inherited user group permission are added', () => {
+    beforeEach(() => {
+      backendSrv.get.reset();
+      backendSrv.post.reset();
+      ctx.ctrl.items = [];
+
+      const inheritedUserGroupItem = {
+        id: 2,
+        name: 'ug1',
+        dashboardId: -1
+      };
+
+      ctx.ctrl.items.push(inheritedUserGroupItem);
+
+      const userGroupItem = {
+        id: 2,
+        name: 'ug1',
+      };
+      ctx.ctrl.groupPicked(userGroupItem);
+    });
+
+    it('should not throw a validation error', () => {
+      expect(ctx.ctrl.error).to.eql('');
+    });
+
+    it('should add both permissions', () => {
+      expect(ctx.ctrl.items.length).to.eql(2);
+    });
+  });
+});

+ 3 - 0
public/app/features/dashboard/all.js

@@ -24,4 +24,7 @@ define([
   './ad_hoc_filters',
   './row/row_ctrl',
   './repeat_option/repeat_option',
+  './acl/acl',
+  './folder_picker/picker',
+  './folder_modal/folder'
 ], function () {});

+ 6 - 0
public/app/features/dashboard/dashboard_ctrl.ts

@@ -127,6 +127,12 @@ export class DashboardCtrl {
       $scope.timezoneChanged = function() {
         $rootScope.$broadcast("refresh");
       };
+
+      $scope.onFolderChange = function(folder) {
+        $scope.dashboard.folderId = folder.id;
+        $scope.dashboard.meta.folderId = folder.id;
+        $scope.dashboard.meta.folderTitle= folder.title;
+      };
     }
 
     init(dashboard) {

+ 0 - 7
public/app/features/dashboard/dashboard_srv.ts

@@ -113,14 +113,8 @@ export class DashboardSrv {
   }
 
   showSaveAsModal() {
-    var newScope = this.$rootScope.$new();
-    newScope.clone = this.dash.getSaveModelClone();
-    newScope.clone.editable = true;
-    newScope.clone.hideControls = false;
-
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<save-dashboard-as-modal dismiss="dismiss()"></save-dashboard-as-modal>',
-      scope: newScope,
       modalClass: 'modal--narrow'
     });
   }
@@ -128,7 +122,6 @@ export class DashboardSrv {
   showSaveModal() {
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<save-dashboard-modal dismiss="dismiss()"></save-dashboard-modal>',
-      scope: this.$rootScope.$new(),
       modalClass: 'modal--narrow'
     });
   }

+ 52 - 83
public/app/features/dashboard/dashnav/dashnav.html

@@ -1,95 +1,64 @@
-<div class="navbar">
-	<div class="navbar-inner">
-		<a class="navbar-brand-btn pointer" ng-click="ctrl.toggleSideMenu()">
-			<span class="navbar-brand-btn-background">
-				<img src="public/img/grafana_icon.svg"></img>
-			</span>
-			<i class="icon-gf icon-gf-grafana_wordmark"></i>
-			<i class="fa fa-caret-down"></i>
-			<i class="fa fa-chevron-left"></i>
-		</a>
+<navbar model="ctrl.navModel">
 
-		<div class="navbar-section-wrapper">
-			<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
-				<i class="icon-gf icon-gf-dashboard"></i>
-				{{ctrl.dashboard.title}}
-				<i class="fa fa-caret-down"></i>
-			</a>
-		</div>
+<ul class="nav dash-playlist-actions" ng-if="ctrl.playlistSrv.isPlaying">
+	<li>
+		<a ng-click="ctrl.playlistSrv.prev()"><i class="fa fa-step-backward"></i></a>
+	</li>
+	<li>
+		<a ng-click="ctrl.playlistSrv.stop()"><i class="fa fa-stop"></i></a>
+	</li>
+	<li>
+		<a ng-click="ctrl.playlistSrv.next()"><i class="fa fa-step-forward"></i></a>
+	</li>
+</ul>
 
-		<ul class="nav dash-playlist-actions" ng-if="ctrl.playlistSrv.isPlaying">
-			<li>
-				<a ng-click="ctrl.playlistSrv.prev()"><i class="fa fa-step-backward"></i></a>
-			</li>
+<ul class="nav pull-left dashnav-action-icons">
+	<li ng-show="::ctrl.dashboard.meta.canStar">
+		<a class="pointer" ng-click="ctrl.starDashboard()">
+			<i class="fa" ng-class="{'fa-star-o': !ctrl.dashboard.meta.isStarred, 'fa-star': ctrl.dashboard.meta.isStarred}" style="color: orange;"></i>
+		</a>
+	</li>
+	<li ng-show="::ctrl.dashboard.meta.canShare" class="dropdown">
+		<a class="pointer" ng-click="ctrl.hideTooltip($event)" bs-tooltip="'Share dashboard'" data-placement="bottom" data-toggle="dropdown"><i class="fa fa-share-square-o"></i></a>
+		<ul class="dropdown-menu">
 			<li>
-				<a ng-click="ctrl.playlistSrv.stop()"><i class="fa fa-stop"></i></a>
+				<a class="pointer" ng-click="ctrl.shareDashboard(0)">
+					<i class="fa fa-link"></i> Link to Dashboard
+					<div class="dropdown-desc">Share an internal link to the current dashboard. Some configuration options available.</div>
+				</a>
 			</li>
 			<li>
-				<a ng-click="ctrl.playlistSrv.next()"><i class="fa fa-step-forward"></i></a>
-			</li>
-		</ul>
-
-		<ul class="nav pull-left dashnav-action-icons">
-			<li ng-show="::ctrl.dashboard.meta.canStar">
-				<a class="pointer" ng-click="ctrl.starDashboard()">
-					<i class="fa" ng-class="{'fa-star-o': !ctrl.dashboard.meta.isStarred, 'fa-star': ctrl.dashboard.meta.isStarred}" style="color: orange;"></i>
+				<a class="pointer" ng-click="ctrl.shareDashboard(1)">
+					<i class="icon-gf icon-gf-snapshot"></i>Snapshot
+					<div class="dropdown-desc">Interactive, publically accessible dashboard. Sensitive data is stripped out.</div>
 				</a>
 			</li>
-			<li ng-show="::ctrl.dashboard.meta.canShare" class="dropdown">
-				<a class="pointer" ng-click="ctrl.hideTooltip($event)" bs-tooltip="'Share dashboard'" data-placement="bottom" data-toggle="dropdown"><i class="fa fa-share-square-o"></i></a>
-				<ul class="dropdown-menu">
-					<li>
-						<a class="pointer" ng-click="ctrl.shareDashboard(0)">
-							<i class="fa fa-link"></i> Link to Dashboard
-							<div class="dropdown-desc">Share an internal link to the current dashboard. Some configuration options available.</div>
-						</a>
-					</li>
-					<li>
-						<a class="pointer" ng-click="ctrl.shareDashboard(1)">
-							<i class="icon-gf icon-gf-snapshot"></i>Snapshot
-							<div class="dropdown-desc">Interactive, publically accessible dashboard. Sensitive data is stripped out.</div>
-						</a>
-					</li>
-					<li>
-						<a class="pointer" ng-click="ctrl.shareDashboard(2)">
-							<i class="fa fa-cloud-upload"></i>Export
-							<div class="dropdown-desc">Export the dashboard to a JSON file for others and to share on Grafana.com</div>
-						</a>
-					</li>
-				</ul>
-			</li>
-			<li ng-show="::ctrl.dashboard.meta.canSave">
-				<a ng-click="ctrl.saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
-			</li>
-			<li ng-if="::ctrl.dashboard.snapshot.originalUrl">
-				<a ng-href="{{ctrl.dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom"><i class="fa fa-link"></i></a>
-			</li>
-			<li class="dropdown">
-				<a class="pointer" data-toggle="dropdown">
-					<i class="fa fa-cog"></i>
+			<li>
+				<a class="pointer" ng-click="ctrl.shareDashboard(2)">
+					<i class="fa fa-cloud-upload"></i>Export
+					<div class="dropdown-desc">Export the dashboard to a JSON file for others and to share on Grafana.com</div>
 				</a>
-				<ul class="dropdown-menu dropdown-menu--navbar">
-					<li ng-repeat="navItem in ::ctrl.navModel.menu" ng-class="{active: navItem.active}">
-						<a class="pointer" ng-href="{{::navItem.url}}" ng-click="ctrl.navItemClicked(navItem, $event)">
-							<i class="{{::navItem.icon}}" ng-show="::navItem.icon"></i>
-							{{::navItem.title}}
-						</a>
-					</li>
-				</ul>
 			</li>
 		</ul>
+	</li>
+	<li ng-show="::ctrl.dashboard.meta.canSave">
+		<a ng-click="ctrl.saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
+	</li>
+	<li ng-if="::ctrl.dashboard.snapshot.originalUrl">
+		<a ng-href="{{ctrl.dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom"><i class="fa fa-link"></i></a>
+	</li>
+</ul>
 
-		<ul class="nav pull-right">
-			<li ng-show="ctrl.dashboard.meta.fullscreen" class="dashnav-back-to-dashboard">
-				<a ng-click="ctrl.exitFullscreen()">
-					Back to dashboard
-				</a>
-			</li>
-			<li>
-				<gf-time-picker dashboard="ctrl.dashboard"></gf-time-picker>
-			</li>
-		</ul>
-	</div>
-</div>
+<ul class="nav pull-right">
+	<li ng-show="ctrl.dashboard.meta.fullscreen" class="dashnav-back-to-dashboard">
+		<a ng-click="ctrl.exitFullscreen()">
+			Back to dashboard
+		</a>
+	</li>
+	<li>
+		<gf-time-picker dashboard="ctrl.dashboard"></gf-time-picker>
+	</li>
+</ul>
+
+</navbar>
 
-<dashboard-search></dashboard-search>

+ 3 - 10
public/app/features/dashboard/dashnav/dashnav.ts

@@ -105,7 +105,7 @@ export class DashNavCtrl {
 
       if (alerts > 0) {
         confirmText = 'DELETE';
-        text2 = `This dashboad contains ${alerts} alerts. Deleting this dashboad will also delete those alerts`;
+        text2 = `This dashboard contains ${alerts} alerts. Deleting this dashboad will also delete those alerts`;
       }
 
       appEvents.emit('confirm-modal', {
@@ -140,15 +140,8 @@ export class DashNavCtrl {
       var newWindow = window.open(uri);
     }
 
-    showSearch() {
-      this.$rootScope.appEvent('show-dash-search');
-    }
-
-    navItemClicked(navItem, evt) {
-      if (navItem.clickHandler) {
-        navItem.clickHandler();
-        evt.preventDefault();
-      }
+    onFolderChange(folderId) {
+      this.dashboard.folderId = folderId;
     }
 }
 

+ 24 - 0
public/app/features/dashboard/folder_modal/folder.html

@@ -0,0 +1,24 @@
+<div class="modal-body">
+  <div class="modal-header">
+    <h2 class="modal-header-title">
+			<i class="fa fa-folder"></i>
+      <span class="p-l-1">New Dashboard Folder</span>
+    </h2>
+
+    <a class="modal-header-close" ng-click="ctrl.dismiss();">
+      <i class="fa fa-remove"></i>
+    </a>
+  </div>
+
+  <form name="ctrl.saveForm" ng-submit="ctrl.create()" class="modal-content folder-modal" novalidate>
+    <div class="p-t-2">
+      <div class="gf-form">
+        <input type="text" ng-model="ctrl.title" required give-focus="true" class="gf-form-input" placeholder="Enter folder name" />
+      </div>
+    </div>
+    <div class="gf-form-button-row text-center">
+      <button type="submit" class="btn btn-success" ng-disabled="ctrl.saveForm.$invalid">Create</button>
+      <a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
+    </div>
+  </form>
+</div>

+ 45 - 0
public/app/features/dashboard/folder_modal/folder.ts

@@ -0,0 +1,45 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+export class FolderCtrl {
+  title: string;
+  dismiss: any;
+
+  /** @ngInject */
+  constructor(private backendSrv, private $scope, private $location) {
+  }
+
+  create() {
+    if (!this.title || this.title.trim().length === 0) {
+      return;
+    }
+
+    const title = this.title.trim();
+
+    return this.backendSrv.createDashboardFolder(title).then(result => {
+      appEvents.emit('alert-success', ['Folder Created', 'OK']);
+      this.dismiss();
+
+      var folderUrl = '/dashboard/db/' + result.slug;
+      this.$location.url(folderUrl);
+    });
+  }
+}
+
+export function folderModal() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/features/dashboard/folder_modal/folder.html',
+    controller: FolderCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      dismiss: "&"
+    }
+  };
+}
+
+coreModule.directive('folderModal', folderModal);

+ 83 - 0
public/app/features/dashboard/folder_picker/picker.ts

@@ -0,0 +1,83 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+export class FolderPickerCtrl {
+  initialTitle: string;
+  initialFolderId: number;
+  labelClass: string;
+  onChange: any;
+  rootName = 'Root';
+
+  private folder: any;
+
+  /** @ngInject */
+  constructor(private backendSrv, private $scope, private $sce) {
+    if (!this.labelClass) {
+      this.labelClass = "width-7";
+    }
+
+    if (this.initialFolderId > 0) {
+      this.getOptions('').then(result => {
+        this.folder = _.find(result, {value: this.initialFolderId});
+      });
+    } else {
+      this.folder = {text: this.initialTitle, value: null};
+    }
+  }
+
+  getOptions(query) {
+    var params = {
+      query: query,
+      type: 'dash-folder',
+    };
+
+    return this.backendSrv.search(params).then(result => {
+      if (query === "") {
+        result.unshift({title: this.rootName, value: 0});
+      }
+
+      return _.map(result, item => {
+        return {text: item.title, value: item.id};
+      });
+    });
+  }
+
+  onFolderChange(option) {
+    this.onChange({$folder: {id: option.value, title: option.text}});
+  }
+
+}
+
+const template = `
+<div class="gf-form">
+  <label class="gf-form-label {{ctrl.labelClass}}">Folder</label>
+  <div class="dropdown">
+    <gf-form-dropdown model="ctrl.folder"
+      get-options="ctrl.getOptions($query)"
+      on-change="ctrl.onFolderChange($option)">
+    </gf-form-dropdown>
+  </div>
+</div>
+`;
+
+export function folderPicker() {
+  return {
+    restrict: 'E',
+    template: template,
+    controller: FolderPickerCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      initialTitle: "<",
+      initialFolderId: '<',
+      labelClass: '@',
+      rootName: '@',
+      onChange: '&'
+    }
+  };
+}
+
+coreModule.directive('folderPicker', folderPicker);

+ 0 - 1
public/app/features/dashboard/import/dash_import.html

@@ -1,4 +1,3 @@
-<div class="modal-body">
 
 	<div class="modal-header">
 		<h2 class="modal-header-title">

+ 2 - 0
public/app/features/dashboard/model.ts

@@ -36,6 +36,7 @@ export class DashboardModel {
   meta: any;
   events: any;
   editMode: boolean;
+  folderId: number;
 
   constructor(data, meta?) {
     if (!data) {
@@ -64,6 +65,7 @@ export class DashboardModel {
     this.version = data.version || 0;
     this.links = data.links || [];
     this.gnetId = data.gnetId || null;
+    this.folderId = data.folderId || null;
 
     this.rows = [];
     if (data.rows) {

+ 15 - 31
public/app/features/dashboard/partials/settings.html

@@ -4,7 +4,7 @@
 	</h2>
 
 	<ul class="gf-tabs">
-		<li class="gf-tabs-item" ng-repeat="tab in ::['General', 'Rows', 'Links', 'Time picker', 'Metadata']">
+		<li class="gf-tabs-item" ng-repeat="tab in ::['General', 'Links', 'Time picker']">
 			<a class="gf-tabs-link" ng-click="editor.index = $index" ng-class="{active: editor.index === $index}">
 				{{::tab}}
 			</a>
@@ -38,17 +38,22 @@
 				</bootstrap-tagsinput>
 			</div>
 
-			<div class="gf-form">
-				<label class="gf-form-label width-7">Timezone</label>
-				<div class="gf-form-select-wrapper">
-					<select ng-model="dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]" ng-change="timezoneChanged()"></select>
-				</div>
-			</div>
+      <folder-picker ng-if="!dashboardMeta.isFolder"
+										 initial-title="dashboardMeta.folderTitle"
+										 on-change="onFolderChange($folder)"
+										 label-class="width-7">
+			</folder-picker>
 		</div>
 
     <div class="section">
-      <h5 class="section-heading">Toggles</h5>
+      <h5 class="section-heading">Options</h5>
       <div class="gf-form-group">
+        <div class="gf-form">
+          <label class="gf-form-label width-11">Timezone</label>
+          <div class="gf-form-select-wrapper">
+            <select ng-model="dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]" ng-change="timezoneChanged()"></select>
+          </div>
+        </div>
         <gf-form-switch class="gf-form"
                         label="Editable"
                         tooltip="Uncheck, then save and reload to disable all dashboard editing"
@@ -116,28 +121,7 @@
 	</div>
 
 	<div ng-if="editor.index == 4">
-		<h5 class="section-heading">Dashboard info</h5>
-		<div class="gf-form-group">
-			<div class="gf-form">
-				<span class="gf-form-label width-10">Last updated at:</span>
-				<span class="gf-form-label width-18">{{dashboard.formatDate(dashboardMeta.updated)}}</span>
-			</div>
-			<div class="gf-form">
-				<span class="gf-form-label width-10">Last updated by:</span>
-				<span class="gf-form-label width-18">{{dashboardMeta.updatedBy}}&nbsp;</span>
-			</div>
-			<div class="gf-form">
-				<span class="gf-form-label width-10">Created at:</span>
-				<span class="gf-form-label width-18">{{dashboard.formatDate(dashboardMeta.created)}}&nbsp;</span>
-			</div>
-			<div class="gf-form">
-				<span class="gf-form-label width-10">Created by:</span>
-				<span class="gf-form-label width-18">{{dashboardMeta.createdBy}}&nbsp;</span>
-			</div>
-			<div class="gf-form">
-				<span class="gf-form-label width-10">Current version:</span>
-				<span class="gf-form-label width-18">{{dashboardMeta.version}}&nbsp;</span>
-			</div>
-		</div>
+		<acl-settings dashboard="dashboard"></acl-settings>
 	</div>
+
 </div>

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

@@ -18,9 +18,15 @@ const  template = `
 	<form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
 		<div class="p-t-2">
 			<div class="gf-form">
-				<label class="gf-form-label">New name</label>
+				<label class="gf-form-label width-7">New name</label>
 				<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required>
 			</div>
+      <div class="gf-form">
+        <folder-picker initial-title="ctrl.folderTitle"
+                       on-change="ctrl.onFolderChange($folder)"
+                       label-class="width-7">
+        </folder-picker>
+      </div>
 		</div>
 
 		<div class="gf-form-button-row text-center">
@@ -33,6 +39,7 @@ const  template = `
 
 export class SaveDashboardAsModalCtrl {
   clone: any;
+  folderTitle: any;
   dismiss: () => void;
 
   /** @ngInject */
@@ -43,6 +50,7 @@ export class SaveDashboardAsModalCtrl {
     this.clone.title += ' Copy';
     this.clone.editable = true;
     this.clone.hideControls = false;
+    this.folderTitle = dashboard.meta.folderTitle || 'Root';
 
     // remove alerts
     this.clone.rows.forEach(row => {
@@ -63,6 +71,10 @@ export class SaveDashboardAsModalCtrl {
       this.save();
     }
   }
+
+  onFolderChange(folder) {
+    this.clone.folderId = folder.id;
+  }
 }
 
 export function saveDashboardAsDirective() {

+ 3 - 1
public/app/features/org/all.js

@@ -1,7 +1,6 @@
 define([
   './org_users_ctrl',
   './profile_ctrl',
-  './org_users_ctrl',
   './select_org_ctrl',
   './change_password_ctrl',
   './newOrgCtrl',
@@ -9,4 +8,7 @@ define([
   './orgApiKeysCtrl',
   './orgDetailsCtrl',
   './prefs_control',
+  './user_groups_ctrl',
+  './user_group_details_ctrl',
+  './create_user_group_modal',
 ], function () {});

+ 38 - 0
public/app/features/org/create_user_group_modal.ts

@@ -0,0 +1,38 @@
+///<reference path="../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+export class CreateUserGroupCtrl {
+  userGroupName = '';
+
+  /** @ngInject */
+  constructor(private backendSrv, private $scope, $sce, private $location) {
+  }
+
+  createUserGroup() {
+    this.backendSrv.post('/api/user-groups', {name: this.userGroupName}).then((result) => {
+      if (result.userGroupId) {
+        this.$location.path('/org/user-groups/edit/' + result.userGroupId);
+      }
+      this.dismiss();
+    });
+  }
+
+  dismiss() {
+    appEvents.emit('hide-modal');
+  }
+}
+
+export function createUserGroupModal() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/features/org/partials/create_user_group.html',
+    controller: CreateUserGroupCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+  };
+}
+
+coreModule.directive('createUserGroupModal', createUserGroupModal);

+ 26 - 0
public/app/features/org/partials/create_user_group.html

@@ -0,0 +1,26 @@
+<div class="modal-body">
+  <div class="modal-header">
+		<h2 class="modal-header-title">
+			<span class="p-l-1">Create User Group</span>
+		</h2>
+
+		<a class="modal-header-close" ng-click="ctrl.dismiss();">
+			<i class="fa fa-remove"></i>
+		</a>
+	</div>
+
+	<div class="modal-content">
+		<form name="ctrl.createUserGroupForm" class="gf-form-group" novalidate>
+      <div class="p-t-2">
+        <div class="gf-form-inline">
+          <div class="gf-form max-width-21">
+            <input type="text" class="gf-form-input" ng-model='ctrl.userGroupName' required give-focus="true" placeholder="Enter User Group Name"></input>
+          </div>
+          <div class="gf-form">
+            <button class="btn gf-form-btn btn-success" ng-click="ctrl.createUserGroup();ctrl.dismiss();">Create</button>
+          </div>
+        </div>
+      </div>
+		</form>
+	</div>
+</div>

+ 49 - 0
public/app/features/org/partials/user_group_details.html

@@ -0,0 +1,49 @@
+<navbar model="ctrl.navModel"></navbar>
+
+<div class="page-container">
+	<div class="page-header">
+		<h1>Edit User Group</h1>
+	</div>
+
+	<form name="userGroupDetailsForm" class="gf-form-group gf-form-inline">
+		<div class="gf-form">
+			<span class="gf-form-label width-10">Name</span>
+			<input type="text" required ng-model="ctrl.userGroup.name" class="gf-form-input max-width-14" >
+		</div>
+
+		<div class="gf-form">
+			<button type="submit" class="btn btn-success" ng-click="ctrl.update()">Update</button>
+		</div>
+	</form>
+
+  <div class="gf-form-group">
+    <h3 class="page-heading">User Group Members</h3>
+
+    <form name="ctrl.addMemberForm" class="gf-form-group">
+      <div class="gf-form">
+        <span class="gf-form-label width-10">User</span>
+        <user-picker user-picked="ctrl.userPicked($user)"></user-picker>
+      </div>
+    </form>
+
+    <table class="grafana-options-table" ng-show="ctrl.userGroupMembers.length > 0">
+      <tr>
+        <th>Username</th>
+        <th>Email</th>
+        <th></th>
+      </tr>
+      <tr ng-repeat="member in ctrl.userGroupMembers">
+        <td>{{member.login}}</td>
+        <td>{{member.email}}</td>
+        <td style="width: 1%">
+          <a ng-click="ctrl.removeUserGroupMember(member)" class="btn btn-danger btn-mini">
+            <i class="fa fa-remove"></i>
+          </a>
+        </td>
+      </tr>
+    </table>
+    <div>
+  <em class="muted" ng-hide="ctrl.userGroupMembers.length > 0">
+    This user group has no members yet.
+  </em>
+</div>

+ 61 - 0
public/app/features/org/partials/user_groups.html

@@ -0,0 +1,61 @@
+<navbar model="ctrl.navModel"></navbar>
+
+<div class="page-container">
+	<div class="page-header">
+		<h1>User Groups</h1>
+
+    <a class="btn btn-success" ng-click="ctrl.openUserGroupModal()">
+      <i class="fa fa-plus"></i>
+      Create User Group
+    </a>
+  </div>
+    <div class="gf-form pull-right width-15 gf-form-group">
+      <span style="position: relative;">
+        <input type="text" class="gf-form-input" placeholder="Find User Group by name" tabindex="1" give-focus="true"
+          ng-model="ctrl.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.get()" />
+      </span>
+    </div>
+  <div class="admin-list-table">
+    <table class="filter-table form-inline" ng-show="ctrl.userGroups.length > 0">
+      <thead>
+        <tr>
+          <th>Id</th>
+          <th>Name</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="userGroup in ctrl.userGroups">
+          <td>{{userGroup.id}}</td>
+          <td>{{userGroup.name}}</td>
+          <td class="text-right">
+            <a href="org/user-groups/edit/{{userGroup.id}}" class="btn btn-inverse btn-small">
+              <i class="fa fa-edit"></i>
+              Edit
+            </a>
+            &nbsp;&nbsp;
+            <a ng-click="ctrl.deleteUserGroup(userGroup)" class="btn btn-danger btn-small">
+              <i class="fa fa-remove"></i>
+            </a>
+          </td>
+        </tr>
+      </tbody>
+
+    </table>
+  </div>
+
+  <div class="admin-list-paging" ng-if="ctrl.showPaging">
+    <ol>
+      <li ng-repeat="page in ctrl.pages">
+        <button
+          class="btn btn-small"
+          ng-class="{'btn-secondary': page.current, 'btn-inverse': !page.current}"
+          ng-click="ctrl.navigateToPage(page)">{{page.page}}</button>
+      </li>
+    </ol>
+  </div>
+
+  <em class="muted" ng-hide="ctrl.userGroups.length > 0">
+    No User Groups found.
+  </em>
+</div>

+ 45 - 0
public/app/features/org/specs/user_group_details_ctrl_specs.ts

@@ -0,0 +1,45 @@
+import '../user_group_details_ctrl';
+import {describe, beforeEach, it, expect, sinon, angularMocks} from 'test/lib/common';
+import UserGroupDetailsCtrl from '../user_group_details_ctrl';
+
+describe('UserGroupDetailsCtrl', () => {
+var ctx: any = {};
+var backendSrv = {
+  searchUsers: sinon.stub().returns(Promise.resolve([])),
+  get: sinon.stub().returns(Promise.resolve([])),
+  post: sinon.stub().returns(Promise.resolve([]))
+};
+
+  beforeEach(angularMocks.module('grafana.core'));
+  beforeEach(angularMocks.module('grafana.controllers'));
+
+  beforeEach(angularMocks.inject(($rootScope, $controller, $q) => {
+    ctx.$q = $q;
+    ctx.scope = $rootScope.$new();
+    ctx.ctrl = $controller(UserGroupDetailsCtrl, {
+      $scope: ctx.scope,
+      backendSrv: backendSrv,
+      $routeParams: {id: 1}
+    });
+  }));
+
+  describe('when user is chosen to be added to user group', () => {
+    beforeEach(() => {
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+      ctx.ctrl.userPicked(userItem);
+    });
+
+    it('should parse the result and save to db', () => {
+      expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/user-groups/1/members');
+      expect(backendSrv.post.getCall(0).args[1].userId).to.eql(2);
+    });
+
+    it('should refresh the list after saving.', () => {
+      expect(backendSrv.get.getCall(0).args[0]).to.eql('/api/user-groups/1');
+      expect(backendSrv.get.getCall(1).args[0]).to.eql('/api/user-groups/1/members');
+    });
+  });
+});

+ 78 - 0
public/app/features/org/user_group_details_ctrl.ts

@@ -0,0 +1,78 @@
+///<reference path="../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+import _ from 'lodash';
+
+export default class UserGroupDetailsCtrl {
+  userGroup: UserGroup;
+  userGroupMembers: User[] = [];
+  navModel: any;
+
+  constructor(private $scope, private $http, private backendSrv, private $routeParams, navModelSrv) {
+    this.navModel = navModelSrv.getOrgNav(3);
+    this.get();
+  }
+
+  get() {
+    if (this.$routeParams && this.$routeParams.id) {
+      this.backendSrv.get(`/api/user-groups/${this.$routeParams.id}`)
+        .then(result => {
+          this.userGroup = result;
+        });
+      this.backendSrv.get(`/api/user-groups/${this.$routeParams.id}/members`)
+        .then(result => {
+          this.userGroupMembers = result;
+        });
+    }
+  }
+
+  removeUserGroupMember(userGroupMember: UserGroupMember) {
+    this.$scope.appEvent('confirm-modal', {
+      title: 'Remove Member',
+      text: 'Are you sure you want to remove ' + userGroupMember.name + ' from this group?',
+      yesText: "Remove",
+      icon: "fa-warning",
+      onConfirm: () => {
+        this.removeMemberConfirmed(userGroupMember);
+      }
+    });
+  }
+
+  removeMemberConfirmed(userGroupMember: UserGroupMember) {
+    this.backendSrv.delete(`/api/user-groups/${this.$routeParams.id}/members/${userGroupMember.userId}`)
+      .then(this.get.bind(this));
+  }
+
+  update() {
+    if (!this.$scope.userGroupDetailsForm.$valid) { return; }
+
+    this.backendSrv.put('/api/user-groups/' + this.userGroup.id, {name: this.userGroup.name});
+  }
+
+  userPicked(user) {
+    this.backendSrv.post(`/api/user-groups/${this.$routeParams.id}/members`, {userId: user.id}).then(() => {
+      this.$scope.$broadcast('user-picker-reset');
+      this.get();
+    });
+  }
+}
+
+export interface UserGroup {
+  id: number;
+  name: string;
+}
+
+export interface User {
+  id: number;
+  name: string;
+  login: string;
+  email: string;
+}
+
+export interface UserGroupMember {
+  userId: number;
+  name: string;
+}
+
+coreModule.controller('UserGroupDetailsCtrl', UserGroupDetailsCtrl);
+

+ 68 - 0
public/app/features/org/user_groups_ctrl.ts

@@ -0,0 +1,68 @@
+///<reference path="../../headers/common.d.ts" />
+
+import coreModule from 'app/core/core_module';
+import {appEvents} from 'app/core/core';
+
+export class UserGroupsCtrl {
+  userGroups: any;
+  pages = [];
+  perPage = 50;
+  page = 1;
+  totalPages: number;
+  showPaging = false;
+  query: any = '';
+  navModel: any;
+
+  /** @ngInject */
+  constructor(private $scope, private $http, private backendSrv, private $location, navModelSrv) {
+    this.navModel = navModelSrv.getOrgNav(3);
+    this.get();
+  }
+
+  get() {
+    this.backendSrv.get(`/api/user-groups/search?perpage=${this.perPage}&page=${this.page}&query=${this.query}`)
+      .then((result) => {
+        this.userGroups = result.userGroups;
+        this.page = result.page;
+        this.perPage = result.perPage;
+        this.totalPages = Math.ceil(result.totalCount / result.perPage);
+        this.showPaging = this.totalPages > 1;
+        this.pages = [];
+
+        for (var i = 1; i < this.totalPages+1; i++) {
+          this.pages.push({ page: i, current: i === this.page});
+        }
+      });
+  }
+
+  navigateToPage(page) {
+    this.page = page.page;
+    this.get();
+  }
+
+  deleteUserGroup(userGroup) {
+    this.$scope.appEvent('confirm-modal', {
+      title: 'Delete',
+      text: 'Are you sure you want to delete User Group ' + userGroup.name + '?',
+      yesText: "Delete",
+      icon: "fa-warning",
+      onConfirm: () => {
+        this.deleteUserGroupConfirmed(userGroup);
+      }
+    });
+  }
+
+  deleteUserGroupConfirmed(userGroup) {
+    this.backendSrv.delete('/api/user-groups/' + userGroup.id)
+      .then(this.get.bind(this));
+  }
+
+  openUserGroupModal() {
+    appEvents.emit('show-modal', {
+      templateHtml: '<create-user-group-modal></create-user-group-modal>',
+      modalClass: 'modal--narrow'
+    });
+  }
+}
+
+coreModule.controller('UserGroupsCtrl', UserGroupsCtrl);

+ 3 - 3
public/app/features/panel/panel_header.ts

@@ -10,7 +10,7 @@ var template = `
   <span class="panel-title-text drag-handle">{{ctrl.panel.title | interpolateTemplateVars:this}}</span>
   <span class="panel-menu-container dropdown">
     <span class="fa fa-caret-down panel-menu-toggle" data-toggle="dropdown"></span>
-    <ul class="dropdown-menu panel-menu" role="menu">
+    <ul class="dropdown-menu dropdown-menu--menu panel-menu" role="menu">
       <li>
         <a ng-click="ctrl.addDataQuery(datasource);">
           <i class="fa fa-cog"></i> Edit <span class="dropdown-menu-item-shortcut">e</span>
@@ -45,7 +45,7 @@ function renderMenuItem(item, ctrl) {
   if (item.href) { html += ` href="${item.href}"`; }
 
   html += `><i class="${item.icon}"></i>`;
-  html += `<span>${item.text}</span>`;
+  html += `<span class="dropdown-item-text">${item.text}</span>`;
 
   if (item.shortcut) {
     html += `<span class="dropdown-menu-item-shortcut">${item.shortcut}</span>`;
@@ -54,7 +54,7 @@ function renderMenuItem(item, ctrl) {
   html += `</a>`;
 
   if (item.submenu) {
-    html += '<ul class="dropdown-menu panel-menu">';
+    html += '<ul class="dropdown-menu dropdown-menu--menu panel-menu">';
     for (let subitem of item.submenu) {
       html += renderMenuItem(subitem, ctrl);
     }

+ 8 - 0
public/app/plugins/panel/dashlist/editor.html

@@ -22,6 +22,14 @@
       <input type="text" class="gf-form-input" placeholder="title query" ng-model="ctrl.panel.query" ng-change="ctrl.refresh()" ng-model-onblur>
     </div>
 
+    <div class="gf-form">
+      <folder-picker  root-name="All"
+                      initial-folder-id="ctrl.panel.folderId"
+											on-change="ctrl.onFolderChange($folder)"
+											label-class="width-6">
+			</folder-picker>
+    </div>
+
     <div class="gf-form">
       <span class="gf-form-label width-6">Tags</span>
       <bootstrap-tagsinput ng-model="ctrl.panel.tags" tagclass="label label-tag" placeholder="add tags" on-tags-updated="ctrl.refresh()">

+ 7 - 0
public/app/plugins/panel/dashlist/module.ts

@@ -19,6 +19,7 @@ class DashListCtrl extends PanelCtrl {
     search: false,
     starred: true,
     headings: true,
+    folderId: 0,
   };
 
   /** @ngInject */
@@ -87,6 +88,7 @@ class DashListCtrl extends PanelCtrl {
       limit: this.panel.limit,
       query: this.panel.query,
       tag: this.panel.tags,
+      folderId: this.panel.folderId
     };
 
     return this.backendSrv.search(params).then(result => {
@@ -123,6 +125,11 @@ class DashListCtrl extends PanelCtrl {
       });
     });
   }
+
+  onFolderChange(folder: any) {
+    this.panel.folderId = folder.id;
+    this.refresh();
+  }
 }
 
 export {DashListCtrl, DashListCtrl as PanelCtrl};

+ 13 - 0
public/app/plugins/panel/permissionlist/editor.html

@@ -0,0 +1,13 @@
+<div>
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Options</h5>
+    <div class="gf-form">
+      <folder-picker  root-name="All"
+                      initial-folder-id="ctrl.panel.folderId"
+											on-change="ctrl.onFolderChange($folder)"
+											label-class="width-6">
+			</folder-picker>
+    </div>
+  </div>
+
+</div>

+ 75 - 0
public/app/plugins/panel/permissionlist/img/icn-singlestat-panel.svg

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="100px" height="100px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve">
+<g>
+	<path fill="#1F1F1F" d="M18.2,4.2c-1.5,0-2.9,0.6-3.9,1.6l-0.5,0.5l-0.5-0.5c-1-1.1-2.4-1.6-3.9-1.6S6.6,4.8,5.5,5.8
+		c-1,1-1.6,2.4-1.6,3.9s0.6,2.9,1.6,3.9l8.3,8.3l8.3-8.3c1-1,1.6-2.4,1.6-3.9s-0.6-2.9-1.6-3.9C21.1,4.8,19.7,4.2,18.2,4.2z
+		 M21,12.5l-7.2,7.2l-7.1-7.1c-0.7-0.7-1.1-1.7-1.1-2.8c0-1,0.4-2,1.1-2.8c0.7-0.7,1.7-1.1,2.8-1.1c1,0,2,0.4,2.8,1.1l0.5,0.5
+		l0.6,0.6l0.6,0.6l0.6-0.6L15,7.5L15.5,7c0.7-0.7,1.7-1.1,2.8-1.1c1,0,2,0.4,2.8,1.1c0.7,0.7,1.1,1.7,1.1,2.8
+		C22.1,10.8,21.7,11.8,21,12.5z"/>
+	<path fill="#1F1F1F" d="M18.2,77.3c-1.5,0-2.9,0.6-3.9,1.6l-0.5,0.5L13.4,79c-1-1.1-2.4-1.6-3.9-1.6S6.6,77.9,5.5,79
+		c-1,1-1.6,2.4-1.6,3.9s0.6,2.9,1.6,3.9l8.3,8.3l8.3-8.3c1-1,1.6-2.4,1.6-3.9S23.2,80,22.1,79C21.1,77.9,19.7,77.3,18.2,77.3z
+		 M21,85.6l-7.2,7.2l-7.1-7.1c-0.7-0.7-1.1-1.7-1.1-2.8c0-1,0.4-2,1.1-2.8C7.4,79.4,8.4,79,9.4,79c1,0,2,0.4,2.8,1.1l0.5,0.5
+		l0.6,0.6l0.6,0.6l0.6-0.6l0.6-0.6l0.5-0.5c0.7-0.7,1.7-1.1,2.8-1.1c1,0,2,0.4,2.8,1.1c0.7,0.7,1.1,1.7,1.1,2.8
+		C22.1,83.9,21.7,84.9,21,85.6z"/>
+	<path fill="#1F1F1F" d="M0,0v100h100V0H0z M22.7,87.4l-8.9,8.9l-8.9-8.9C3.7,86.2,3,84.6,3,82.9s0.7-3.3,1.9-4.5
+		c1.2-1.2,2.8-1.9,4.5-1.9c1.6,0,3.2,0.6,4.4,1.7c1.2-1.1,2.7-1.7,4.4-1.7c1.7,0,3.3,0.7,4.5,1.9c1.2,1.2,1.9,2.8,1.9,4.5
+		S23.9,86.2,22.7,87.4z M22.7,63l-8.9,8.9L4.9,63C3.7,61.8,3,60.2,3,58.5c0-1.7,0.7-3.3,1.9-4.5c1.2-1.2,2.8-1.9,4.5-1.9
+		c1.1,0,2.1,0.3,3,0.7l0,0.1l0,0l-1.3,6.6l0,0l0,0.1l2.3-0.1l-0.6,6.1l0-0.1l0,0l3.4-8.3l-2,0.3l1.2-3.7l0-0.1l0,0l0.3-1.1
+		c0.8-0.3,1.6-0.5,2.4-0.5c1.7,0,3.3,0.7,4.5,1.9c1.2,1.2,1.9,2.8,1.9,4.5C24.6,60.2,23.9,61.8,22.7,63z M22.7,38.7l-8.9,8.9
+		l-8.9-8.9C3.7,37.5,3,35.9,3,34.1s0.7-3.3,1.9-4.5c1.2-1.2,2.8-1.9,4.5-1.9c1.1,0,2.1,0.3,3,0.7l0,0.1l0,0l-1.3,6.6l0,0l0,0.1
+		l2.3-0.1l-0.6,6.1l0-0.1l0,0l3.4-8.3l-2,0.3l1.2-3.7l0-0.1l0,0l0.3-1.1c0.8-0.3,1.6-0.5,2.4-0.5c1.7,0,3.3,0.7,4.5,1.9
+		c1.2,1.2,1.9,2.8,1.9,4.5S23.9,37.5,22.7,38.7z M22.7,14.3l-8.9,8.9l-8.9-8.9C3.7,13.1,3,11.5,3,9.8s0.7-3.3,1.9-4.5
+		c1.2-1.2,2.8-1.9,4.5-1.9c1.6,0,3.2,0.6,4.4,1.7C15,4,16.6,3.4,18.2,3.4c1.7,0,3.3,0.7,4.5,1.9c1.2,1.2,1.9,2.8,1.9,4.5
+		S23.9,13.1,22.7,14.3z M96.6,94.4c0,1-0.8,1.8-1.8,1.8H33.4c-1,0-1.8-0.8-1.8-1.8V78.6c0-1,0.8-1.8,1.8-1.8h61.4
+		c1,0,1.8,0.8,1.8,1.8V94.4z M96.6,69.9c0,1-0.8,1.8-1.8,1.8H33.4c-1,0-1.8-0.8-1.8-1.8V54.1c0-1,0.8-1.8,1.8-1.8h61.4
+		c1,0,1.8,0.8,1.8,1.8V69.9z M96.6,45.4c0,1-0.8,1.8-1.8,1.8H33.4c-1,0-1.8-0.8-1.8-1.8V29.6c0-1,0.8-1.8,1.8-1.8h61.4
+		c1,0,1.8,0.8,1.8,1.8V45.4z M96.6,20.9c0,1-0.8,1.8-1.8,1.8H33.4c-1,0-1.8-0.8-1.8-1.8V5.1c0-1,0.8-1.8,1.8-1.8h61.4
+		c1,0,1.8,0.8,1.8,1.8V20.9z"/>
+	<path fill="#1F1F1F" d="M18.2,53c-0.5,0-1,0.1-1.4,0.2l-0.7,2.1c0.6-0.4,1.3-0.6,2.1-0.6c1,0,2,0.4,2.8,1.1
+		c0.7,0.7,1.1,1.7,1.1,2.8c0,1-0.4,2-1.1,2.8l-7.2,7.2l-7.1-7.1c-0.7-0.7-1.1-1.7-1.1-2.8c0-1,0.4-2,1.1-2.8
+		c0.7-0.7,1.7-1.1,2.8-1.1c0.5,0,1,0.1,1.5,0.3l0.3-1.6c-0.6-0.2-1.2-0.3-1.8-0.3c-1.5,0-2.9,0.6-3.9,1.6c-1,1-1.6,2.4-1.6,3.9
+		c0,1.5,0.6,2.9,1.6,3.9l8.3,8.3l8.3-8.3c1-1,1.6-2.4,1.6-3.9c0-1.5-0.6-2.9-1.6-3.9C21.1,53.5,19.7,53,18.2,53z"/>
+	<path fill="#1F1F1F" d="M18.2,28.6c-0.5,0-1,0.1-1.4,0.2l-0.7,2.1c0.6-0.4,1.3-0.6,2.1-0.6c1,0,2,0.4,2.8,1.1
+		c0.7,0.7,1.1,1.7,1.1,2.8c0,1-0.4,2-1.1,2.8l-7.2,7.2l-7.1-7.1c-0.7-0.7-1.1-1.7-1.1-2.8c0-1,0.4-2,1.1-2.8
+		c0.7-0.7,1.7-1.1,2.8-1.1c0.5,0,1,0.1,1.5,0.3l0.3-1.6c-0.6-0.2-1.2-0.3-1.8-0.3c-1.5,0-2.9,0.6-3.9,1.6c-1,1-1.6,2.4-1.6,3.9
+		s0.6,2.9,1.6,3.9l8.3,8.3l8.3-8.3c1-1,1.6-2.4,1.6-3.9s-0.6-2.9-1.6-3.9C21.1,29.2,19.7,28.6,18.2,28.6z"/>
+	<path fill="#898989" d="M94.7,3.3H33.4c-1,0-1.8,0.8-1.8,1.8v15.8c0,1,0.8,1.8,1.8,1.8h61.4c1,0,1.8-0.8,1.8-1.8V5.1
+		C96.6,4.1,95.7,3.3,94.7,3.3z"/>
+	<path fill="#898989" d="M94.7,27.8H33.4c-1,0-1.8,0.8-1.8,1.8v15.8c0,1,0.8,1.8,1.8,1.8h61.4c1,0,1.8-0.8,1.8-1.8V29.6
+		C96.6,28.6,95.7,27.8,94.7,27.8z"/>
+	<path fill="#898989" d="M94.7,52.3H33.4c-1,0-1.8,0.8-1.8,1.8v15.8c0,1,0.8,1.8,1.8,1.8h61.4c1,0,1.8-0.8,1.8-1.8V54.1
+		C96.6,53.1,95.7,52.3,94.7,52.3z"/>
+	<path fill="#898989" d="M94.7,76.8H33.4c-1,0-1.8,0.8-1.8,1.8v15.8c0,1,0.8,1.8,1.8,1.8h61.4c1,0,1.8-0.8,1.8-1.8V78.6
+		C96.6,77.6,95.7,76.8,94.7,76.8z"/>
+	<path fill="#04A64D" d="M18.2,3.4c-1.6,0-3.2,0.6-4.4,1.7C12.6,4,11.1,3.4,9.4,3.4c-1.7,0-3.3,0.7-4.5,1.9C3.7,6.5,3,8.1,3,9.8
+		s0.7,3.3,1.9,4.5l8.9,8.9l8.9-8.9c1.2-1.2,1.9-2.8,1.9-4.5s-0.7-3.3-1.9-4.5C21.5,4.1,19.9,3.4,18.2,3.4z M22.1,13.7L13.8,22
+		l-8.3-8.3c-1-1-1.6-2.4-1.6-3.9s0.6-2.9,1.6-3.9c1-1,2.4-1.6,3.9-1.6s2.9,0.6,3.9,1.6l0.5,0.5l0.5-0.5c1-1,2.4-1.6,3.9-1.6
+		c1.5,0,2.9,0.6,3.9,1.6c1,1,1.6,2.4,1.6,3.9S23.2,12.7,22.1,13.7z"/>
+	<path fill="#04A64D" d="M18.2,5.9c-1,0-2,0.4-2.8,1.1L15,7.5l-0.6,0.6l-0.6,0.6l-0.6-0.6l-0.6-0.6L12.2,7c-0.7-0.7-1.7-1.1-2.8-1.1
+		c-1,0-2,0.4-2.8,1.1C5.9,7.8,5.5,8.7,5.5,9.8c0,1,0.4,2,1.1,2.8l7.1,7.1l7.2-7.2c0.7-0.7,1.1-1.7,1.1-2.8c0-1-0.4-2-1.1-2.8
+		C20.2,6.3,19.3,5.9,18.2,5.9z"/>
+	<path fill="#04A64D" d="M18.2,76.5c-1.6,0-3.2,0.6-4.4,1.7c-1.2-1.1-2.7-1.7-4.4-1.7c-1.7,0-3.3,0.7-4.5,1.9
+		C3.7,79.6,3,81.2,3,82.9s0.7,3.3,1.9,4.5l8.9,8.9l8.9-8.9c1.2-1.2,1.9-2.8,1.9-4.5s-0.7-3.3-1.9-4.5C21.5,77.2,19.9,76.5,18.2,76.5
+		z M22.1,86.8l-8.3,8.3l-8.3-8.3c-1-1-1.6-2.4-1.6-3.9S4.5,80,5.5,79c1-1,2.4-1.6,3.9-1.6s2.9,0.6,3.9,1.6l0.5,0.5l0.5-0.5
+		c1-1,2.4-1.6,3.9-1.6c1.5,0,2.9,0.6,3.9,1.6c1,1,1.6,2.4,1.6,3.9S23.2,85.8,22.1,86.8z"/>
+	<path fill="#04A64D" d="M18.2,79c-1,0-2,0.4-2.8,1.1L15,80.6l-0.6,0.6l-0.6,0.6l-0.6-0.6l-0.6-0.6l-0.5-0.5
+		c-0.7-0.7-1.7-1.1-2.8-1.1c-1,0-2,0.4-2.8,1.1c-0.7,0.7-1.1,1.7-1.1,2.8c0,1,0.4,2,1.1,2.8l7.1,7.1l7.2-7.2
+		c0.7-0.7,1.1-1.7,1.1-2.8c0-1-0.4-2-1.1-2.8C20.2,79.4,19.3,79,18.2,79z"/>
+	<path fill="#EB242A" d="M18.2,27.8c-0.8,0-1.7,0.2-2.4,0.5l-0.3,1.1l0,0l0,0.1l-1.2,3.7l2-0.3l-3.4,8.3l0,0l0,0.1l0.6-6.1l-2.3,0.1
+		l0-0.1l0,0l1.3-6.6l0,0l0-0.1c-0.9-0.5-1.9-0.7-3-0.7c-1.7,0-3.3,0.7-4.5,1.9C3.7,30.8,3,32.4,3,34.1s0.7,3.3,1.9,4.5l8.9,8.9
+		l8.9-8.9c1.2-1.2,1.9-2.8,1.9-4.5s-0.7-3.3-1.9-4.5C21.5,28.4,19.9,27.8,18.2,27.8z M22.1,38.1l-8.3,8.3l-8.3-8.3
+		c-1-1-1.6-2.4-1.6-3.9s0.6-2.9,1.6-3.9c1-1,2.4-1.6,3.9-1.6c0.6,0,1.2,0.1,1.8,0.3l-0.3,1.6c-0.5-0.2-1-0.3-1.5-0.3
+		c-1,0-2,0.4-2.8,1.1c-0.7,0.7-1.1,1.7-1.1,2.8c0,1,0.4,2,1.1,2.8l7.1,7.1l7.2-7.2c0.7-0.7,1.1-1.7,1.1-2.8c0-1-0.4-2-1.1-2.8
+		c-0.7-0.7-1.7-1.1-2.8-1.1c-0.8,0-1.5,0.2-2.1,0.6l0.7-2.1c0.5-0.1,0.9-0.2,1.4-0.2c1.5,0,2.9,0.6,3.9,1.6c1,1,1.6,2.4,1.6,3.9
+		S23.2,37,22.1,38.1z"/>
+	<path fill="#EB242A" d="M18.2,52.1c-0.8,0-1.7,0.2-2.4,0.5l-0.3,1.1l0,0l0,0.1l-1.2,3.7l2-0.3l-3.4,8.3l0,0l0,0.1l0.6-6.1l-2.3,0.1
+		l0-0.1l0,0l1.3-6.6l0,0l0-0.1c-0.9-0.5-1.9-0.7-3-0.7c-1.7,0-3.3,0.7-4.5,1.9C3.7,55.2,3,56.8,3,58.5c0,1.7,0.7,3.3,1.9,4.5
+		l8.9,8.9l8.9-8.9c1.2-1.2,1.9-2.8,1.9-4.5c0-1.7-0.7-3.3-1.9-4.5C21.5,52.8,19.9,52.1,18.2,52.1z M22.1,62.4l-8.3,8.3l-8.3-8.3
+		c-1-1-1.6-2.4-1.6-3.9c0-1.5,0.6-2.9,1.6-3.9c1-1,2.4-1.6,3.9-1.6c0.6,0,1.2,0.1,1.8,0.3l-0.3,1.6c-0.5-0.2-1-0.3-1.5-0.3
+		c-1,0-2,0.4-2.8,1.1c-0.7,0.7-1.1,1.7-1.1,2.8c0,1,0.4,2,1.1,2.8l7.1,7.1l7.2-7.2c0.7-0.7,1.1-1.7,1.1-2.8c0-1-0.4-2-1.1-2.8
+		c-0.7-0.7-1.7-1.1-2.8-1.1c-0.8,0-1.5,0.2-2.1,0.6l0.7-2.1c0.5-0.1,0.9-0.2,1.4-0.2c1.5,0,2.9,0.6,3.9,1.6c1,1,1.6,2.4,1.6,3.9
+		C23.8,60,23.2,61.4,22.1,62.4z"/>
+</g>
+</svg>

+ 30 - 0
public/app/plugins/panel/permissionlist/module.html

@@ -0,0 +1,30 @@
+<section class="card-section card-list-layout-list">
+    <ol class="card-list">
+      <li class="card-item-wrapper" ng-repeat="permission in ctrl.userPermissions">
+        <div class="card-item card-item--alert">
+          <div class="card-item-header">
+            <div class="card-item-sub-name">{{permission.permissionName}}</div>
+          </div>
+          <div class="card-item-body">
+            <div class="card-item-details">
+              <div class="card-item-notice">{{permission.userLogin}}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </li>
+    <li class="card-item-wrapper" ng-repeat="permission in ctrl.userGroupPermissions">
+        <div class="card-item card-item--alert">
+          <div class="card-item-header">
+            <div class="card-item-sub-name">{{permission.permissionName}}</div>
+          </div>
+          <div class="card-item-body">
+            <div class="card-item-details">
+              <div class="card-item-notice">{{permission.userGroup}}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </li>
+  </ol>
+</section>

+ 60 - 0
public/app/plugins/panel/permissionlist/module.ts

@@ -0,0 +1,60 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import config from 'app/core/config';
+import {PanelCtrl} from 'app/plugins/sdk';
+import {impressions} from 'app/features/dashboard/impression_store';
+
+class PermissionListCtrl extends PanelCtrl {
+  static templateUrl = 'module.html';
+
+  userPermissions: any[];
+  userGroupPermissions: any[];
+  roles: any[];
+
+  panelDefaults = {
+    folderId: 0
+  };
+
+  /** @ngInject */
+  constructor($scope, $injector, private backendSrv) {
+    super($scope, $injector);
+    _.defaults(this.panel, this.panelDefaults);
+
+    this.events.on('refresh', this.onRefresh.bind(this));
+    this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
+
+    this.getPermissions();
+  }
+
+  onInitEditMode() {
+    this.editorTabIndex = 1;
+    this.addEditorTab('Options', 'public/app/plugins/panel/permissionlist/editor.html');
+  }
+
+  onRefresh() {
+    var promises = [];
+
+    promises.push(this.getPermissions());
+
+    return Promise.all(promises)
+      .then(this.renderingCompleted.bind(this));
+  }
+
+  onFolderChange(folder: any) {
+    this.panel.folderId = folder.id;
+    this.refresh();
+  }
+
+  getPermissions() {
+  return this.backendSrv.get(`/api/dashboards/id/${this.panel.folderId}/acl`)
+    .then(result => {
+      this.userPermissions = _.filter(result, p => { return p.userId > 0;});
+      this.userGroupPermissions = _.filter(result, p => { return p.userGroupId > 0;});
+      // this.roles = this.setRoles(result);
+    });
+  }
+
+}
+
+export {PermissionListCtrl, PermissionListCtrl as PanelCtrl};

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