diff --git a/pkg/api/api.go b/pkg/api/api.go index ae9b8e7803f..af080c73929 100644 --- a/pkg/api/api.go +++ b/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 diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index df0cbbd745c..60b3aba56fb 100644 --- a/pkg/api/dashboard.go +++ b/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) + } +} + +func GetDashboard(c *middleware.Context) Response { + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0) + if rsp != nil { + return rsp } - isStarred, err := isDashboardStarredByUser(c, query.Result.Id) - if err != nil { - c.JsonApiErr(500, "Error while checking if dashboard was starred by user", err) - return + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canView, err := guardian.CanView(); err != nil || !canView { + fmt.Printf("%v", err) + return dashboardGuardianResponse(err) } - dash := query.Result + canEdit, _ := guardian.CanEdit() + canSave, _ := guardian.CanSave() + canAdmin, _ := guardian.CanAdmin() + + isStarred, err := isDashboardStarredByUser(c, dash.Id) + if err != nil { + return ApiError(500, "Error while checking if dashboard was starred by user", err) + } // Finding creator and last updater of the dashboard updater, creator := "Anonymous", "Anonymous" @@ -62,29 +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 +} + +func DeleteDashboard(c *middleware.Context) Response { + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0) + if rsp != nil { + return rsp } - cmd := m.DeleteDashboardCommand{Slug: slug, OrgId: c.OrgId} + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id} if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to delete dashboard", err) - return + return ApiError(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": query.Result.Title} - - 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") - - dashQuery := m.GetDashboardQuery{Id: dashboardId, OrgId: c.OrgId} - if err := bus.Dispatch(&dashQuery); err != nil { - return ApiError(404, "Dashboard not found", nil) + dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId")) + if rsp != nil { + return rsp } - versionQuery := m.GetDashboardVersionQuery{DashboardId: dashboardId, Version: apiCmd.Version, OrgId: c.OrgId} + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + 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) diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go new file mode 100644 index 00000000000..e1791563f28 --- /dev/null +++ b/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, "") +} diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go new file mode 100644 index 00000000000..162bac0aba8 --- /dev/null +++ b/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 +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go new file mode 100644 index 00000000000..cd27639c4e4 --- /dev/null +++ b/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) + }) +} diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 5ae752bea91..72336693363 100644 --- a/pkg/api/datasources_test.go +++ b/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) }) diff --git a/pkg/api/dtos/acl.go b/pkg/api/dtos/acl.go new file mode 100644 index 00000000000..41f560e794b --- /dev/null +++ b/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"` +} diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 9ef9a96edc4..0be0537527b 100644 --- a/pkg/api/dtos/dashboard.go +++ b/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 { diff --git a/pkg/api/index.go b/pkg/api/index.go index bf7a9fc1759..db8e51db9af 100644 --- a/pkg/api/index.go +++ b/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"}, + }, }) } diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index a6c2da26dd8..040aef0474e 100644 --- a/pkg/api/playlist.go +++ b/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) } diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index 29a806ce23d..1d059e06be5 100644 --- a/pkg/api/playlist_play.go +++ b/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 diff --git a/pkg/api/render.go b/pkg/api/render.go index 5284c7831bb..2a5ce27c210 100644 --- a/pkg/api/render.go +++ b/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", ""), } diff --git a/pkg/api/search.go b/pkg/api/search.go index c68dc51e986..3998fb0298c 100644 --- a/pkg/api/search.go +++ b/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) diff --git a/pkg/api/user.go b/pkg/api/user.go index 7e8bad91ab0..60ed1609987 100644 --- a/pkg/api/user.go +++ b/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 { diff --git a/pkg/api/user_group.go b/pkg/api/user_group.go new file mode 100644 index 00000000000..183b7e80f65 --- /dev/null +++ b/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) +} diff --git a/pkg/api/user_group_members.go b/pkg/api/user_group_members.go new file mode 100644 index 00000000000..09f3def59bd --- /dev/null +++ b/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") +} diff --git a/pkg/api/user_group_test.go b/pkg/api/user_group_test.go new file mode 100644 index 00000000000..730461ac8e8 --- /dev/null +++ b/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) + }) + }) + }) +} diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index db36054e76d..e98172ba0cf 100644 --- a/pkg/components/renderer/renderer.go +++ b/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) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 00354a00d03..b5921e6a534 100644 --- a/pkg/metrics/metrics.go +++ b/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") diff --git a/pkg/middleware/render_auth.go b/pkg/middleware/render_auth.go index 3a57660c9bf..d2f9c1b2b1a 100644 --- a/pkg/middleware/render_auth.go +++ b/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() diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go new file mode 100644 index 00000000000..9b65ec8eb33 --- /dev/null +++ b/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 +} diff --git a/pkg/models/dashboard_acl_test.go b/pkg/models/dashboard_acl_test.go new file mode 100644 index 00000000000..35357ff1cc9 --- /dev/null +++ b/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") + }) + }) +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 0463e9c209b..eadf4cd15e5 100644 --- a/pkg/models/dashboards.go +++ b/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 } diff --git a/pkg/models/dashboards_test.go b/pkg/models/dashboards_test.go index ee16508dc8a..0ec773dfb97 100644 --- a/pkg/models/dashboards_test.go +++ b/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) + }) + }) } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index d7a918751ef..47f368bdef1 100644 --- a/pkg/models/org_user.go +++ b/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 } - return r == other + 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 false } func (r *RoleType) UnmarshalJSON(data []byte) error { diff --git a/pkg/models/user.go b/pkg/models/user.go index e3981dc3880..34158c6ca20 100644 --- a/pkg/models/user.go +++ b/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"` diff --git a/pkg/models/user_group.go b/pkg/models/user_group.go new file mode 100644 index 00000000000..e5e361526d4 --- /dev/null +++ b/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"` +} diff --git a/pkg/models/user_group_member.go b/pkg/models/user_group_member.go new file mode 100644 index 00000000000..23ddd84601b --- /dev/null +++ b/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"` +} diff --git a/pkg/plugins/dashboards.go b/pkg/plugins/dashboards.go index 5d26766b8e5..37e3d8c0076 100644 --- a/pkg/plugins/dashboards.go +++ b/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, }) } } diff --git a/pkg/plugins/dashboards_updater.go b/pkg/plugins/dashboards_updater.go index 52a623e73dd..4c40e536d14 100644 --- a/pkg/plugins/dashboards_updater.go +++ b/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) diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index e831b566b22..b2cdac30b80 100644 --- a/pkg/services/alerting/notifier.go +++ b/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 { diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go new file mode 100644 index 00000000000..a05213e87c8 --- /dev/null +++ b/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 +} diff --git a/pkg/services/search/handlers.go b/pkg/services/search/handlers.go index a4905d6fa58..9197c8f77e6 100644 --- a/pkg/services/search/handlers.go +++ b/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) -} diff --git a/pkg/services/search/handlers_test.go b/pkg/services/search/handlers_test.go index bb355ec146f..92a96dfd102 100644 --- a/pkg/services/search/handlers_test.go +++ b/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") - }) - - }) }) } diff --git a/pkg/services/search/json_index.go b/pkg/services/search/json_index.go deleted file mode 100644 index 79c238b27f9..00000000000 --- a/pkg/services/search/json_index.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/search/json_index_test.go b/pkg/services/search/json_index_test.go deleted file mode 100644 index 145e1ac1e99..00000000000 --- a/pkg/services/search/json_index_test.go +++ /dev/null @@ -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) - }) - - }) -} diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go index 159637013f5..a95a5dc3d4a 100644 --- a/pkg/services/search/models.go +++ b/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 } diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index daf4f774717..7b27f5b9ca4 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/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) diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 50b02bf0970..ebf7977e724 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/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 { diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go new file mode 100644 index 00000000000..8d3a2455f1f --- /dev/null +++ b/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 +} diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/sqlstore/dashboard_acl_test.go new file mode 100644 index 00000000000..55f4b24808b --- /dev/null +++ b/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) + }) + + }) + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index a055500592b..84ca3f37686 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/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(¤tUserCmd) + 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 +} diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go index 0b296650b4b..c5ef7d374f0 100644 --- a/pkg/services/sqlstore/dashboard_version.go +++ b/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, diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index 8ac636c1682..919efcd0004 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/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} diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index 51bc759fdaf..8cb1cf9af9e 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/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() diff --git a/pkg/services/sqlstore/migrations/dashboard_acl.go b/pkg/services/sqlstore/migrations/dashboard_acl.go new file mode 100644 index 00000000000..af85e48af0d --- /dev/null +++ b/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)) +} diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index ee0cc1d893f..ed8d2c73a5d 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/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", + })) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 38072fe88e4..32d94fc1c29 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/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) { diff --git a/pkg/services/sqlstore/migrations/user_group_mig.go b/pkg/services/sqlstore/migrations/user_group_mig.go new file mode 100644 index 00000000000..589def0c504 --- /dev/null +++ b/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])) +} diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index e7c718fc9a8..59d96c4f8ca 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/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) + }) + + }) + }) }) }) }) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index e1b9dcc1da7..56b1b3f0323 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/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) diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 177f465dc96..bdb1b56509a 100644 --- a/pkg/services/sqlstore/user.go +++ b/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 { diff --git a/pkg/services/sqlstore/user_group.go b/pkg/services/sqlstore/user_group.go new file mode 100644 index 00000000000..c90141310ab --- /dev/null +++ b/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 +} diff --git a/pkg/services/sqlstore/user_group_test.go b/pkg/services/sqlstore/user_group_test.go new file mode 100644 index 00000000000..76056f98837 --- /dev/null +++ b/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) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index decb4682552..a65b7226eb6 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/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("Can return list of users matching query on login name", func() { - query := models.SearchUsersQuery{Query: "loginuser1", Page: 1, Limit: 3} - err = SearchUsers(&query) + 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) - So(err, ShouldBeNil) - So(len(query.Result.Users), ShouldEqual, 1) - So(query.Result.TotalCount, ShouldEqual, 1) + err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[0].Id, Permission: m.PERMISSION_EDIT}) + So(err, ShouldBeNil) + + err = SavePreferences(&m.SavePreferencesCommand{UserId: users[0].Id, OrgId: users[0].OrgId, HomeDashboardId: 1, Theme: "dark"}) + So(err, ShouldBeNil) + + 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 +} diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index f910e124a49..0546a394f1e 100644 --- a/public/app/core/components/grafana_app.ts +++ b/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) { diff --git a/public/app/core/components/navbar/navbar.html b/public/app/core/components/navbar/navbar.html index 085e2eef920..1294fefddca 100644 --- a/public/app/core/components/navbar/navbar.html +++ b/public/app/core/components/navbar/navbar.html @@ -3,15 +3,8 @@ - - - - - - -
- + + diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts index 62258a25e7d..923b080ed92 100644 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ b/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") diff --git a/public/app/core/components/user_group_picker.ts b/public/app/core/components/user_group_picker.ts new file mode 100644 index 00000000000..05f2c016f40 --- /dev/null +++ b/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 = ` +