diff --git a/package.json b/package.json index 06a83ecac18..80fa9a699f8 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "karma-webpack": "^2.0.4", "lint-staged": "^6.0.0", "load-grunt-tasks": "3.5.2", + "mobx-react-devtools": "^4.2.15", "mocha": "^4.0.1", "ng-annotate-loader": "^0.6.1", "ng-annotate-webpack-plugin": "^0.2.1-pre", @@ -91,7 +92,7 @@ "typescript": "^2.6.2", "webpack": "^3.10.0", "webpack-bundle-analyzer": "^2.9.0", - "webpack-cleanup-plugin": "^0.5.1", + "webpack-cleanup-plugin": "^0.5.1", "webpack-merge": "^4.1.0", "zone.js": "^0.7.2" }, @@ -148,8 +149,8 @@ "react": "^16.2.0", "react-dom": "^16.2.0", "react-grid-layout": "^0.16.2", - "react-popper": "^0.7.5", "react-highlight-words": "^0.10.0", + "react-popper": "^0.7.5", "react-select": "^1.1.0", "react-sizeme": "^2.3.6", "remarkable": "^1.7.1", diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index d11122016c4..42ea091ef10 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/guardian" ) func ValidateOrgAlert(c *middleware.Context) { @@ -62,9 +63,22 @@ func GetAlerts(c *middleware.Context) Response { return ApiError(500, "List alerts failed", err) } + alertDTOs, resp := transformToDTOs(query.Result, c) + if resp != nil { + return resp + } + + return Json(200, alertDTOs) +} + +func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) { + if len(alerts) == 0 { + return []*dtos.AlertRule{}, nil + } + dashboardIds := make([]int64, 0) alertDTOs := make([]*dtos.AlertRule, 0) - for _, alert := range query.Result { + for _, alert := range alerts { dashboardIds = append(dashboardIds, alert.DashboardId) alertDTOs = append(alertDTOs, &dtos.AlertRule{ Id: alert.Id, @@ -83,10 +97,8 @@ func GetAlerts(c *middleware.Context) Response { DashboardIds: dashboardIds, } - if len(alertDTOs) > 0 { - if err := bus.Dispatch(&dashboardsQuery); err != nil { - return ApiError(500, "List alerts failed", err) - } + if err := bus.Dispatch(&dashboardsQuery); err != nil { + return nil, ApiError(500, "List alerts failed", err) } //TODO: should be possible to speed this up with lookup table @@ -98,7 +110,26 @@ func GetAlerts(c *middleware.Context) Response { } } - return Json(200, alertDTOs) + permissionsQuery := models.GetDashboardPermissionsForUserQuery{ + DashboardIds: dashboardIds, + OrgId: c.OrgId, + UserId: c.SignedInUser.UserId, + OrgRole: c.SignedInUser.OrgRole, + } + + if err := bus.Dispatch(&permissionsQuery); err != nil { + return nil, ApiError(500, "List alerts failed", err) + } + + for _, alert := range alertDTOs { + for _, perm := range permissionsQuery.Result { + if alert.DashboardId == perm.DashboardId { + alert.CanEdit = perm.Permission > 1 + } + } + } + + return alertDTOs, nil } // POST /api/alerts/test @@ -155,24 +186,6 @@ func GetAlert(c *middleware.Context) Response { return Json(200, &query.Result) } -// DEL /api/alerts/:id -func DelAlert(c *middleware.Context) Response { - alertId := c.ParamsInt64(":alertId") - - if alertId == 0 { - return ApiError(401, "Failed to parse alertid", nil) - } - - cmd := models.DeleteAlertCommand{AlertId: alertId} - - if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete alert", err) - } - - var resp = map[string]interface{}{"alertId": alertId} - return Json(200, resp) -} - func GetAlertNotifiers(c *middleware.Context) Response { return Json(200, alerting.GetNotifiers()) } @@ -267,6 +280,22 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R //POST /api/alerts/:alertId/pause func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response { alertId := c.ParamsInt64("alertId") + + query := models.GetAlertByIdQuery{Id: alertId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Get Alert failed", err) + } + + guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser) + if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { + if err != nil { + return ApiError(500, "Error while checking permissions for Alert", err) + } + + return ApiError(403, "Access denied to this dashboard and alert", nil) + } + cmd := models.PauseAlertCommand{ OrgId: c.OrgId, AlertIds: []int64{alertId}, diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go new file mode 100644 index 00000000000..6b030053e22 --- /dev/null +++ b/pkg/api/alerting_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAlertingApiEndpoint(t *testing.T) { + Convey("Given an alert in a dashboard with an acl", t, func() { + + singleAlert := &m.Alert{Id: 1, DashboardId: 1, Name: "singlealert"} + + bus.AddHandler("test", func(query *m.GetAlertByIdQuery) error { + query.Result = singleAlert + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + Convey("When user is editor and not in the ACL", func() { + Convey("Should not be able to pause the alert", func() { + cmd := dtos.PauseAlertCommand{ + AlertId: 1, + Paused: true, + } + postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + CallPauseAlert(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is editor and dashboard has default ACL", func() { + aclMockResp = []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + Convey("Should be able to pause the alert", func() { + cmd := dtos.PauseAlertCommand{ + AlertId: 1, + Paused: true, + } + postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + CallPauseAlert(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) +} + +func CallPauseAlert(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.PauseAlertCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func postAlertScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PauseAlertCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PauseAlert(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 0bf95557abc..e3845520795 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -7,7 +7,9 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "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/annotations" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" ) @@ -51,6 +53,10 @@ func (e *CreateAnnotationError) Error() string { } func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response { + if canSave, err := canSaveByDashboardId(c, cmd.DashboardId); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + repo := annotations.GetRepository() if cmd.Text == "" { @@ -178,6 +184,10 @@ func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Resp repo := annotations.GetRepository() + if resp := canSave(c, repo, annotationId); resp != nil { + return resp + } + item := annotations.Item{ OrgId: c.OrgId, UserId: c.UserId, @@ -228,6 +238,10 @@ func DeleteAnnotationById(c *middleware.Context) Response { repo := annotations.GetRepository() annotationId := c.ParamsInt64(":annotationId") + if resp := canSave(c, repo, annotationId); resp != nil { + return resp + } + err := repo.Delete(&annotations.DeleteParams{ Id: annotationId, }) @@ -243,6 +257,10 @@ func DeleteAnnotationRegion(c *middleware.Context) Response { repo := annotations.GetRepository() regionId := c.ParamsInt64(":regionId") + if resp := canSave(c, repo, regionId); resp != nil { + return resp + } + err := repo.Delete(&annotations.DeleteParams{ RegionId: regionId, }) @@ -253,3 +271,50 @@ func DeleteAnnotationRegion(c *middleware.Context) Response { return ApiSuccess("Annotation region deleted") } + +func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error) { + if dashboardId == 0 && !c.SignedInUser.HasRole(m.ROLE_EDITOR) { + return false, nil + } + + if dashboardId > 0 { + guardian := guardian.NewDashboardGuardian(dashboardId, c.OrgId, c.SignedInUser) + if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { + return false, err + } + } + + return true, nil +} + +func canSave(c *middleware.Context, repo annotations.Repository, annotationId int64) Response { + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationId, OrgId: c.OrgId}) + + if err != nil || len(items) == 0 { + return ApiError(500, "Could not find annotation to update", err) + } + + dashboardId := items[0].DashboardId + + if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + return nil +} + +func canSaveByRegionId(c *middleware.Context, repo annotations.Repository, regionId int64) Response { + items, err := repo.Find(&annotations.ItemQuery{RegionId: regionId, OrgId: c.OrgId}) + + if err != nil || len(items) == 0 { + return ApiError(500, "Could not find annotation to update", err) + } + + dashboardId := items[0].DashboardId + + if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + return nil +} diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go new file mode 100644 index 00000000000..480962d8826 --- /dev/null +++ b/pkg/api/annotations_test.go @@ -0,0 +1,242 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAnnotationsApiEndpoint(t *testing.T) { + Convey("Given an annotation without a dashboard id", t, func() { + cmd := dtos.PostAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + } + + updateCmd := dtos.UpdateAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + } + + Convey("When user is an Org Viewer", func() { + role := m.ROLE_VIEWER + Convey("Should not be allowed to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + Convey("Should be able to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) + + Convey("Given an annotation with a dashboard id and the dashboard does not have an acl", t, func() { + cmd := dtos.PostAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + DashboardId: 1, + PanelId: 1, + } + + updateCmd := dtos.UpdateAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + Id: 1, + } + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + Convey("When user is an Org Viewer", func() { + role := m.ROLE_VIEWER + Convey("Should not be allowed to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + Convey("Should be able to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) +} + +type fakeAnnotationsRepo struct { +} + +func (repo *fakeAnnotationsRepo) Delete(params *annotations.DeleteParams) error { + return nil +} +func (repo *fakeAnnotationsRepo) Save(item *annotations.Item) error { + item.Id = 1 + return nil +} +func (repo *fakeAnnotationsRepo) Update(item *annotations.Item) error { + return nil +} +func (repo *fakeAnnotationsRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) { + annotations := []*annotations.ItemDTO{{Id: 1}} + return annotations, nil +} + +var fakeAnnoRepo *fakeAnnotationsRepo + +func postAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PostAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PostAnnotation(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func putAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return UpdateAnnotation(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Put(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index ea082ff4741..086d7345483 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -144,13 +144,13 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/teams", func(teamsRoute RouteRegister) { teamsRoute.Get("/:teamId", wrap(GetTeamById)) teamsRoute.Get("/search", wrap(SearchTeams)) - teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", wrap(DeleteTeamById)) - teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) - }, reqOrgAdmin) + teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam)) + teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) + teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById)) + teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers)) + teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember)) + }) // org information available to all users. apiRoute.Group("/org", func(orgRoute RouteRegister) { @@ -243,19 +243,21 @@ func (hs *HttpServer) registerRoutes() { // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) - dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard)) + dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard)) dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff)) - dashboardRoute.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) + dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) dashboardRoute.Get("/home", wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) + dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser)) + dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) - dashIdRoute.Post("/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { aclRoute.Get("/", wrap(GetDashboardAclList)) @@ -317,8 +319,8 @@ func (hs *HttpServer) registerRoutes() { annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById)) annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation)) annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion)) - annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) - }, reqEditorRole) + annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) + }) // error test r.Get("/metrics/error", wrap(GenerateError)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go new file mode 100644 index 00000000000..33fc1688603 --- /dev/null +++ b/pkg/api/common_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + macaron "gopkg.in/macaron.v1" + + . "github.com/smartystreets/goconvey/convey" +) + +func loggedInUserScenario(desc string, url string, fn scenarioFunc) { + loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) +} + +func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + if sc.handlerFunc != nil { + return sc.handlerFunc(sc.context) + } + + return nil + }) + + switch method { + case "GET": + sc.m.Get(routePattern, sc.defaultHandler) + case "DELETE": + sc.m.Delete(routePattern, sc.defaultHandler) + } + + fn(sc) + }) +} + +func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +type scenarioContext struct { + m *macaron.Macaron + context *middleware.Context + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string +} + +func (sc *scenarioContext) exec() { + sc.m.ServeHTTP(sc.resp, sc.req) +} + +type scenarioFunc func(c *scenarioContext) +type handlerFunc func(c *middleware.Context) Response + +func setupScenarioContext(url string) *scenarioContext { + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + return sc +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 87c42884e31..206b7f890b5 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -38,9 +38,9 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error) func dashboardGuardianResponse(err error) Response { if err != nil { return ApiError(500, "Error while checking dashboard permissions", err) - } else { - return ApiError(403, "Access denied to this dashboard", nil) } + + return ApiError(403, "Access denied to this dashboard", nil) } func GetDashboard(c *middleware.Context) Response { @@ -51,7 +51,6 @@ func GetDashboard(c *middleware.Context) Response { guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) if canView, err := guardian.CanView(); err != nil || !canView { - fmt.Printf("%v", err) return dashboardGuardianResponse(err) } @@ -99,6 +98,7 @@ func GetDashboard(c *middleware.Context) Response { return ApiError(500, "Dashboard folder could not be read", err) } meta.FolderTitle = query.Result.Title + meta.FolderSlug = query.Result.Slug } // make sure db version is in sync with json model version @@ -158,7 +158,14 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { dash := cmd.GetDashboardModel() - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + dashId := dash.Id + + // if new dashboard, use parent folder permissions instead + if dashId == 0 { + dashId = cmd.FolderId + } + + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -431,3 +438,19 @@ func GetDashboardTags(c *middleware.Context) { c.JSON(200, query.Result) } + +func GetFoldersForSignedInUser(c *middleware.Context) Response { + title := c.Query("query") + query := m.GetFoldersForSignedInUserQuery{ + OrgId: c.OrgId, + SignedInUser: c.SignedInUser, + Title: title, + } + + err := bus.Dispatch(&query) + if err != nil { + return ApiError(500, "Failed to get folders from database", err) + } + + return Json(200, query.Result) +} diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go index 88cc74b9d1c..6eb11047723 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_acl.go @@ -51,6 +51,14 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom }) } + if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if err != nil { + return ApiError(500, "Error while checking dashboard permissions", err) + } + + return ApiError(403, "Cannot remove own admin permission for a folder", nil) + } + if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty { return ApiError(409, err.Error(), err) @@ -70,6 +78,14 @@ func DeleteDashboardAcl(c *middleware.Context) Response { return dashboardGuardianResponse(err) } + if okToDelete, err := guardian.CheckPermissionBeforeRemove(m.PERMISSION_ADMIN, aclId); err != nil || !okToDelete { + if err != nil { + return ApiError(500, "Error while checking dashboard permissions", err) + } + + return ApiError(403, "Cannot remove own admin permission for a folder", nil) + } + 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) diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index e22e625dcf9..467045e360a 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_acl_test.go @@ -3,8 +3,10 @@ package api import ( "testing" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" @@ -37,6 +39,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { return nil }) + // This tests four scenarios: + // 1. user is an org admin + // 2. user is an org editor AND has been granted admin permission for the dashboard + // 3. user is an org viewer AND has been granted edit permission for the dashboard + // 4. user is an org editor AND has no permissions for the dashboard + Convey("When user is org admin", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { Convey("Should be able to access ACL", func() { @@ -54,9 +62,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and has admin permission in the ACL", func() { + Convey("When user is org editor and has admin permission in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) Convey("Should be able to access ACL", func() { sc.handlerFunc = GetDashboardAclList @@ -67,7 +75,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { return nil @@ -81,6 +89,52 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { + return nil + }) + + Convey("Should not be able to delete their own Admin permission", func() { + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("Should not be able to downgrade their own Admin permission", func() { + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: TestUserID, Permission: m.PERMISSION_EDIT}, + }, + } + + postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + CallPostAcl(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("Should be able to update permissions", func() { + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: TestUserID, Permission: m.PERMISSION_ADMIN}, + {UserId: 2, Permission: m.PERMISSION_EDIT}, + }, + } + + postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + CallPostAcl(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + Convey("When user is a member of a team in the ACL with admin permission", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"}) @@ -99,11 +153,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and has edit permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + Convey("When user is org viewer and has edit permission in the ACL", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) - Convey("Should not be able to access ACL", func() { + // Getting the permissions is an Admin permission + Convey("Should not be able to get list of permissions from ACL", func() { sc.handlerFunc = GetDashboardAclList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -111,7 +166,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { @@ -127,7 +182,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and not in the ACL", func() { + Convey("When user is org editor and not in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { Convey("Should not be able to access ACL", func() { @@ -172,3 +227,32 @@ func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardA return dtos } + +func CallPostAcl(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return UpdateDashboardAcl(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index e6228878625..c83479242fc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -2,12 +2,8 @@ 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" @@ -32,6 +28,10 @@ func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) var fakeRepo *fakeDashboardRepo +// This tests two main scenarios. If a user has access to execute an action on a dashboard: +// 1. and the dashboard is in a folder which does not have an acl +// 2. and the dashboard is in a folder which does have an acl + func TestDashboardApiEndpoint(t *testing.T) { Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { fakeDash := m.NewDashboard("Child dash") @@ -70,6 +70,10 @@ func TestDashboardApiEndpoint(t *testing.T) { }), } + // This tests two scenarios: + // 1. user is an org viewer + // 2. user is an org editor + Convey("When user is an Org Viewer", func() { role := m.ROLE_VIEWER @@ -200,6 +204,14 @@ func TestDashboardApiEndpoint(t *testing.T) { }), } + // This tests six scenarios: + // 1. user is an org viewer AND has no permissions for this dashboard + // 2. user is an org editor AND has no permissions for this dashboard + // 3. user is an org viewer AND has been granted edit permission for the dashboard + // 4. user is an org viewer AND all viewers have edit permission for this dashboard + // 5. user is an org viewer AND has been granted an admin permission + // 6. user is an org editor AND has been granted a view permission + Convey("When user is an Org Viewer and has no permissions for this dashboard", func() { role := m.ROLE_VIEWER @@ -488,20 +500,7 @@ func postDashboardScenario(desc string, url string, routePattern string, role m. 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 := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c sc.context.UserId = TestUserID diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 72336693363..490393727d6 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -2,17 +2,11 @@ package api import ( "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" "testing" "github.com/grafana/grafana/pkg/models" - macaron "gopkg.in/macaron.v1" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" . "github.com/smartystreets/goconvey/convey" ) @@ -54,88 +48,3 @@ func TestDataSourcesProxy(t *testing.T) { }) }) } - -func loggedInUserScenario(desc string, url string, fn scenarioFunc) { - loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) -} - -func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { - Convey(desc+" "+url, func() { - defer bus.ClearBusHandlers() - - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - - sc.defaultHandler = wrap(func(c *middleware.Context) Response { - sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role - if sc.handlerFunc != nil { - return sc.handlerFunc(sc.context) - } - - return nil - }) - - switch method { - case "GET": - sc.m.Get(routePattern, sc.defaultHandler) - case "DELETE": - sc.m.Delete(routePattern, sc.defaultHandler) - } - - fn(sc) - }) -} - -func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { - sc.resp = httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - So(err, ShouldBeNil) - sc.req = req - - return sc -} - -func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { - sc.resp = httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - q := req.URL.Query() - for k, v := range queryParams { - q.Add(k, v) - } - req.URL.RawQuery = q.Encode() - So(err, ShouldBeNil) - sc.req = req - - return sc -} - -type scenarioContext struct { - m *macaron.Macaron - context *middleware.Context - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string -} - -func (sc *scenarioContext) exec() { - sc.m.ServeHTTP(sc.resp, sc.req) -} - -type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *middleware.Context) Response diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index 4285ebc89cc..c32edb6e51c 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -20,6 +20,7 @@ type AlertRule struct { EvalData *simplejson.Json `json:"evalData"` ExecutionError string `json:"executionError"` DashbboardUri string `json:"dashboardUri"` + CanEdit bool `json:"canEdit"` } type AlertNotification struct { diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 0be0537527b..e101b0ff623 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -26,6 +26,7 @@ type DashboardMeta struct { IsFolder bool `json:"isFolder"` FolderId int64 `json:"folderId"` FolderTitle string `json:"folderTitle"` + FolderSlug string `json:"folderSlug"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/index.go b/pkg/api/index.go index 1b836356189..5beecefab88 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -102,8 +102,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { } dashboardChildNavs := []*dtos.NavLink{ - {Text: "Home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true}, - {Divider: true, HideFromTabs: true}, + {Text: "Home", Id: "home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true}, + {Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true}, {Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "gicon gicon-manage"}, {Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "gicon gicon-playlists"}, {Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "gicon gicon-snapshots"}, @@ -261,7 +261,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { if c.IsGrafanaAdmin { cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ - Divider: true, HideFromTabs: true, + Divider: true, HideFromTabs: true, Id: "admin-divider", Text: "Text", }) cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ Text: "Server Admin", diff --git a/pkg/models/alert.go b/pkg/models/alert.go index fa3f4b466a8..b378c5cf90f 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -159,10 +159,6 @@ type SetAlertStateCommand struct { Timestamp time.Time } -type DeleteAlertCommand struct { - AlertId int64 -} - //Queries type GetAlertsQuery struct { OrgId int64 diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 091f27ec413..69e490ece11 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -199,6 +199,14 @@ type GetDashboardsQuery struct { Result []*Dashboard } +type GetDashboardPermissionsForUserQuery struct { + DashboardIds []int64 + OrgId int64 + UserId int64 + OrgRole RoleType + Result []*DashboardPermissionForUser +} + type GetDashboardsByPluginIdQuery struct { OrgId int64 PluginId string @@ -209,3 +217,21 @@ type GetDashboardSlugByIdQuery struct { Id int64 Result string } + +type GetFoldersForSignedInUserQuery struct { + OrgId int64 + SignedInUser *SignedInUser + Title string + Result []*DashboardFolder +} + +type DashboardFolder struct { + Id int64 `json:"id"` + Title string `json:"title"` +} + +type DashboardPermissionForUser struct { + DashboardId int64 `json:"dashboardId"` + Permission PermissionType `json:"permission"` + PermissionName string `json:"permissionName"` +} diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index 02f927a76ba..a6cd7a33318 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -10,14 +10,16 @@ type Repository interface { } type ItemQuery struct { - OrgId int64 `json:"orgId"` - From int64 `json:"from"` - To int64 `json:"to"` - AlertId int64 `json:"alertId"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - Tags []string `json:"tags"` - Type string `json:"type"` + OrgId int64 `json:"orgId"` + From int64 `json:"from"` + To int64 `json:"to"` + AlertId int64 `json:"alertId"` + DashboardId int64 `json:"dashboardId"` + PanelId int64 `json:"panelId"` + AnnotationId int64 `json:"annotationId"` + RegionId int64 `json:"regionId"` + Tags []string `json:"tags"` + Type string `json:"type"` Limit int64 `json:"limit"` } diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 1b664c11385..f4056841c33 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -55,6 +55,10 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er return false, err } + return g.checkAcl(permission, acl) +} + +func (g *DashboardGuardian) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) { orgRole := g.user.OrgRole teamAclItems := []*m.DashboardAclInfoDTO{} @@ -79,18 +83,18 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er } } - // do we have group rules? + // do we have team rules? if len(teamAclItems) == 0 { return false, nil } - // load groups + // load teams teams, err := g.getTeams() if err != nil { return false, err } - // evalute group rules + // evalute team rules for _, p := range acl { for _, ug := range teams { if ug.Id == p.TeamId && p.Permission >= permission { @@ -102,7 +106,41 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er return false, nil } -// Returns dashboard acl +func (g *DashboardGuardian) CheckPermissionBeforeRemove(permission m.PermissionType, aclIdToRemove int64) (bool, error) { + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil + } + + acl, err := g.GetAcl() + if err != nil { + return false, err + } + + for i, p := range acl { + if p.Id == aclIdToRemove { + acl = append(acl[:i], acl[i+1:]...) + break + } + } + + return g.checkAcl(permission, acl) +} + +func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil + } + + acl := []*m.DashboardAclInfoDTO{} + + for _, p := range updatePermissions { + acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission}) + } + + return g.checkAcl(permission, acl) +} + +// GetAcl returns dashboard acl func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { if g.acl != nil { return g.acl, nil diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index cb1aec8cd65..96af8bc49ee 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -14,7 +14,6 @@ func init() { bus.AddHandler("sql", SaveAlerts) bus.AddHandler("sql", HandleAlertsQuery) bus.AddHandler("sql", GetAlertById) - bus.AddHandler("sql", DeleteAlertById) bus.AddHandler("sql", GetAllAlertQueryHandler) bus.AddHandler("sql", SetAlertState) bus.AddHandler("sql", GetAlertStatesForDashboard) @@ -61,12 +60,6 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro return nil } -func DeleteAlertById(cmd *m.DeleteAlertCommand) error { - return inTransaction(func(sess *DBSession) error { - return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess) - }) -} - func HandleAlertsQuery(query *m.GetAlertsQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index effffb8bab4..76f1819a18c 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -138,6 +138,17 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I sql.WriteString(`WHERE annotation.org_id = ?`) params = append(params, query.OrgId) + if query.AnnotationId != 0 { + fmt.Print("annotation query") + sql.WriteString(` AND annotation.id = ?`) + params = append(params, query.AnnotationId) + } + + if query.RegionId != 0 { + sql.WriteString(` AND annotation.region_id = ?`) + params = append(params, query.RegionId) + } + if query.AlertId != 0 { sql.WriteString(` AND annotation.alert_id = ?`) params = append(params, query.AlertId) @@ -197,6 +208,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit)) items := make([]*annotations.ItemDTO, 0) + if err := x.Sql(sql.String(), params...).Find(&items); err != nil { return nil, err } diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index 2afd4479b66..d5cee110b9a 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -51,6 +51,20 @@ func TestAnnotations(t *testing.T) { So(err, ShouldBeNil) So(annotation.Id, ShouldBeGreaterThan, 0) + annotation2 := &annotations.Item{ + OrgId: 1, + UserId: 1, + DashboardId: 2, + Text: "hello", + Type: "alert", + Epoch: 20, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + RegionId: 1, + } + err = repo.Save(annotation2) + So(err, ShouldBeNil) + So(annotation2.Id, ShouldBeGreaterThan, 0) + Convey("Can query for annotation", func() { items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, @@ -67,6 +81,28 @@ func TestAnnotations(t *testing.T) { }) }) + Convey("Can query for annotation by id", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + AnnotationId: annotation2.Id, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + So(items[0].Id, ShouldEqual, annotation2.Id) + }) + + Convey("Can query for annotation by region id", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + RegionId: annotation2.RegionId, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + So(items[0].Id, ShouldEqual, annotation2.Id) + }) + Convey("Should not find any when item is outside time range", func() { items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 0b6b60a5e11..81dab375188 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -1,6 +1,7 @@ package sqlstore import ( + "strings" "time" "github.com/grafana/grafana/pkg/bus" @@ -18,6 +19,8 @@ func init() { bus.AddHandler("sql", GetDashboardTags) bus.AddHandler("sql", GetDashboardSlugById) bus.AddHandler("sql", GetDashboardsByPluginId) + bus.AddHandler("sql", GetFoldersForSignedInUser) + bus.AddHandler("sql", GetDashboardPermissionsForUser) } func SaveDashboard(cmd *m.SaveDashboardCommand) error { @@ -291,6 +294,52 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { return err } +func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error { + query.Result = make([]*m.DashboardFolder, 0) + var err error + + if query.SignedInUser.OrgRole == m.ROLE_ADMIN { + sql := `SELECT distinct d.id, d.title + FROM dashboard AS d WHERE d.is_folder = ? + ORDER BY d.title ASC` + + err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result) + } else { + params := make([]interface{}, 0) + sql := `SELECT distinct d.id, d.title + FROM dashboard AS d + LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id + LEFT JOIN team_member AS ugm ON ugm.team_id = da.team_id + LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? + LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ? AND ouRole.org_id = ?` + params = append(params, query.SignedInUser.UserId) + params = append(params, query.SignedInUser.UserId) + params = append(params, query.OrgId) + + sql += `WHERE + d.org_id = ? AND + d.is_folder = 1 AND + ( + (d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) + OR (d.has_acl = 0 AND ouRole.id IS NOT NULL) + )` + params = append(params, query.OrgId) + params = append(params, query.SignedInUser.UserId) + params = append(params, query.SignedInUser.UserId) + + if len(query.Title) > 0 { + sql += " AND d.title " + dialect.LikeStr() + " ?" + params = append(params, "%"+query.Title+"%") + } + + sql += ` ORDER BY d.title ASC` + + err = x.Sql(sql, params...).Find(&query.Result) + } + + return err +} + func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { return inTransaction(func(sess *DBSession) error { dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId} @@ -343,6 +392,76 @@ func GetDashboards(query *m.GetDashboardsQuery) error { return nil } +// GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s) +// The function takes in a list of dashboard ids and the user id and role +func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery) error { + if len(query.DashboardIds) == 0 { + return m.ErrCommandValidationFailed + } + + if query.OrgRole == m.ROLE_ADMIN { + var permissions = make([]*m.DashboardPermissionForUser, 0) + for _, d := range query.DashboardIds { + permissions = append(permissions, &m.DashboardPermissionForUser{ + DashboardId: d, + Permission: m.PERMISSION_ADMIN, + PermissionName: m.PERMISSION_ADMIN.String(), + }) + } + query.Result = permissions + + return nil + } + + params := make([]interface{}, 0) + + // check dashboards that have ACLs via user id, team id or role + sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission + FROM dashboard AS d + LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id + LEFT JOIN team_member as ugm on ugm.team_id = da.team_id + LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? + ` + params = append(params, query.UserId) + + //check the user's role for dashboards that do not have hasAcl set + sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?` + params = append(params, query.UserId) + params = append(params, query.OrgId) + + sql += ` + LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS 'role' + UNION SELECT 2 AS permission, 'Editor' AS 'role' + UNION SELECT 4 AS permission, 'Admin' AS 'role') pt ON ouRole.role = pt.role + WHERE + d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) ` + for _, id := range query.DashboardIds { + params = append(params, id) + } + + sql += ` AND + d.org_id = ? AND + ( + (d.has_acl = ? AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) + OR (d.has_acl = ? AND ouRole.id IS NOT NULL) + ) + group by d.id + order by d.id asc` + params = append(params, dialect.BooleanStr(true)) + params = append(params, query.OrgId) + params = append(params, query.UserId) + params = append(params, query.UserId) + params = append(params, dialect.BooleanStr(false)) + + err := x.Sql(sql, params...).Find(&query.Result) + + for _, p := range query.Result { + p.PermissionName = p.Permission.String() + } + + return err +} + func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error { var dashboards = make([]*m.Dashboard, 0) whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false) diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 8d805c21685..9027f74f33a 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -126,14 +126,10 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { } func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { - dashboardFilter := fmt.Sprintf(`IN ( - SELECT %d - UNION - SELECT folder_id from dashboard where id = %d - )`, query.DashboardId, query.DashboardId) + var err error - rawSQL := ` - SELECT + if query.DashboardId == 0 { + sql := `SELECT da.id, da.org_id, da.dashboard_id, @@ -143,44 +139,71 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { da.role, da.created, da.updated, - u.login AS user_login, - u.email AS user_email, - ug.name AS team - FROM` + dialect.Quote("dashboard_acl") + ` as da - LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id - LEFT OUTER JOIN team ug on ug.id = da.team_id - WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? + '' as user_login, + '' as user_email, + '' as team + FROM dashboard_acl as da + WHERE da.dashboard_id = -1` + query.Result = make([]*m.DashboardAclInfoDTO, 0) + err = x.SQL(sql).Find(&query.Result) - -- Also include default permission if has_acl = 0 + } else { + dashboardFilter := fmt.Sprintf(`IN ( + SELECT %d + UNION + SELECT folder_id from dashboard where id = %d + )`, query.DashboardId, query.DashboardId) - UNION - SELECT - da.id, - da.org_id, - da.dashboard_id, - da.user_id, - da.team_id, - da.permission, - da.role, - da.created, - da.updated, - '' as user_login, - '' as user_email, - '' as team - FROM dashboard_acl as da, - dashboard as dash - LEFT JOIN dashboard folder on dash.folder_id = folder.id - WHERE - dash.id = ? AND ( - dash.has_acl = ` + dialect.BooleanStr(false) + ` or - folder.has_acl = ` + dialect.BooleanStr(false) + ` - ) AND - da.dashboard_id = -1 - ORDER BY 1 ASC - ` + rawSQL := ` + SELECT + da.id, + da.org_id, + da.dashboard_id, + da.user_id, + da.team_id, + da.permission, + da.role, + da.created, + da.updated, + u.login AS user_login, + u.email AS user_email, + ug.name AS team + FROM` + dialect.Quote("dashboard_acl") + ` as da + LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id + LEFT OUTER JOIN team ug on ug.id = da.team_id + WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? - query.Result = make([]*m.DashboardAclInfoDTO, 0) - err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result) + -- Also include default permission if has_acl = 0 + + UNION + SELECT + da.id, + da.org_id, + da.dashboard_id, + da.user_id, + da.team_id, + da.permission, + da.role, + da.created, + da.updated, + '' as user_login, + '' as user_email, + '' as team + FROM dashboard_acl as da, + dashboard as dash + LEFT JOIN dashboard folder on dash.folder_id = folder.id + WHERE + dash.id = ? AND ( + dash.has_acl = ` + dialect.BooleanStr(false) + ` or + folder.has_acl = ` + dialect.BooleanStr(false) + ` + ) AND + da.dashboard_id = -1 + ORDER BY 1 ASC + ` + + query.Result = make([]*m.DashboardAclInfoDTO, 0) + err = x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result) + } for _, p := range query.Result { p.PermissionName = p.Permission.String() diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/sqlstore/dashboard_acl_test.go index bb6363883d6..8b712c73ece 100644 --- a/pkg/services/sqlstore/dashboard_acl_test.go +++ b/pkg/services/sqlstore/dashboard_acl_test.go @@ -232,5 +232,23 @@ func TestDashboardAclDataAccess(t *testing.T) { }) }) + + Convey("Given a root folder", func() { + var rootFolderId int64 = 0 + + Convey("When reading dashboard acl should return default permissions", func() { + query := m.GetDashboardAclInfoListQuery{DashboardId: rootFolderId, OrgId: 1} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + defaultPermissionsId := -1 + So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER) + So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR) + }) + }) }) } diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index a552bd0546a..b5d21b37567 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -459,6 +459,137 @@ func TestDashboardDataAccess(t *testing.T) { }) }) + Convey("Given two dashboard folders", func() { + + folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod") + folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod") + + adminUser := createUser("admin", "Admin", true) + editorUser := createUser("editor", "Editor", false) + viewerUser := createUser("viewer", "Viewer", false) + + Convey("Admin users", func() { + Convey("Should have write access to all dashboard folders", func() { + query := m.GetFoldersForSignedInUserQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN}, + } + + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[1].Id, ShouldEqual, folder2.Id) + }) + + Convey("should have write access to all folders and dashboards", func() { + query := m.GetDashboardPermissionsForUserQuery{ + DashboardIds: []int64{folder1.Id, folder2.Id}, + OrgId: 1, + UserId: adminUser.Id, + OrgRole: m.ROLE_ADMIN, + } + + err := GetDashboardPermissionsForUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, folder1.Id) + So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) + So(query.Result[1].DashboardId, ShouldEqual, folder2.Id) + So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN) + }) + }) + + Convey("Editor users", func() { + query := m.GetFoldersForSignedInUserQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR}, + } + + Convey("Should have write access to all dashboard folders with default ACL", func() { + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[1].Id, ShouldEqual, folder2.Id) + }) + + Convey("should have edit access to folders with default ACL", func() { + query := m.GetDashboardPermissionsForUserQuery{ + DashboardIds: []int64{folder1.Id, folder2.Id}, + OrgId: 1, + UserId: editorUser.Id, + OrgRole: m.ROLE_EDITOR, + } + + err := GetDashboardPermissionsForUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, folder1.Id) + So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT) + So(query.Result[1].DashboardId, ShouldEqual, folder2.Id) + So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT) + }) + + Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() { + updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW) + + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, folder2.Id) + }) + + }) + + Convey("Viewer users", func() { + query := m.GetFoldersForSignedInUserQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER}, + } + + Convey("Should have no write access to any dashboard folders with default ACL", func() { + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 0) + }) + + Convey("should have view access to folders with default ACL", func() { + query := m.GetDashboardPermissionsForUserQuery{ + DashboardIds: []int64{folder1.Id, folder2.Id}, + OrgId: 1, + UserId: viewerUser.Id, + OrgRole: m.ROLE_VIEWER, + } + + err := GetDashboardPermissionsForUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, folder1.Id) + So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW) + So(query.Result[1].DashboardId, ShouldEqual, folder2.Id) + So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW) + }) + + Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() { + updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT) + + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + }) + }) + }) + Convey("Given a plugin with imported dashboards", func() { pluginId := "test-app" diff --git a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx index a12340555e9..f5aa07b454a 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx @@ -24,6 +24,7 @@ describe('AlertRuleList', () => { evalData: {}, executionError: '', dashboardUri: 'db/mygool', + canEdit: true, }, ]) ); diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx index 429504e46d9..d2712706154 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.tsx @@ -147,7 +147,8 @@ export class AlertRuleItem extends React.Component {
- {this.renderText(rule.name)} + {rule.canEdit && {this.renderText(rule.name)}} + {!rule.canEdit && {this.renderText(rule.name)}}
{this.renderText(rule.stateText)} @@ -156,17 +157,30 @@ export class AlertRuleItem extends React.Component {
{rule.info &&
{this.renderText(rule.info)}
}
+
- - - - - + + {rule.canEdit && ( + + + + )} + {!rule.canEdit && ( + + )}
); diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap index 571566503f8..da5fa5f12c4 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap +++ b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap @@ -80,15 +80,16 @@ exports[`AlertRuleList should render 1 rule 1`] = `
- - + { + constructor(props) { + super(props); + this.loadStore(); + } + + loadStore() { + const { nav, folder, view } = this.props; + return folder.load(view.routeParams.get('slug') as string).then(res => { + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions'); + }); + } + + render() { + const { nav, folder, permissions, backendSrv } = this.props; + + if (!folder.folder || !nav.main) { + return

Loading

; + } + + const dashboardId = folder.folder.id; + + return ( +
+ +
+
+

Folder Permissions

+ + + +
+ + +
+
+ ); + } +} diff --git a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx new file mode 100644 index 00000000000..3355b657f03 --- /dev/null +++ b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { FolderSettings } from './FolderSettings'; +import { RootStore } from 'app/stores/RootStore/RootStore'; +import { backendSrv } from 'test/mocks/common'; +import { shallow } from 'enzyme'; + +describe('FolderSettings', () => { + let wrapper; + let page; + + beforeAll(() => { + backendSrv.getDashboard.mockReturnValue( + Promise.resolve({ + dashboard: { + id: 1, + title: 'Folder Name', + }, + meta: { + slug: 'folder-name', + canSave: true, + }, + }) + ); + + const store = RootStore.create( + {}, + { + backendSrv: backendSrv, + } + ); + + wrapper = shallow(); + return wrapper + .dive() + .instance() + .loadStore() + .then(() => { + page = wrapper.dive(); + }); + }); + + it('should set the title input field', () => { + const titleInput = page.find('.gf-form-input'); + expect(titleInput).toHaveLength(1); + expect(titleInput.prop('value')).toBe('Folder Name'); + }); + + it('should update title and enable save button when changed', () => { + const titleInput = page.find('.gf-form-input'); + const disabledSubmitButton = page.find('button[type="submit"]'); + expect(disabledSubmitButton.prop('disabled')).toBe(true); + + titleInput.simulate('change', { target: { value: 'New Title' } }); + + const updatedTitleInput = page.find('.gf-form-input'); + expect(updatedTitleInput.prop('value')).toBe('New Title'); + const enabledSubmitButton = page.find('button[type="submit"]'); + expect(enabledSubmitButton.prop('disabled')).toBe(false); + }); + + it('should disable save button if title is changed back to old title', () => { + const titleInput = page.find('.gf-form-input'); + + titleInput.simulate('change', { target: { value: 'Folder Name' } }); + + const enabledSubmitButton = page.find('button[type="submit"]'); + expect(enabledSubmitButton.prop('disabled')).toBe(true); + }); + + it('should disable save button if title is changed to empty string', () => { + const titleInput = page.find('.gf-form-input'); + + titleInput.simulate('change', { target: { value: '' } }); + + const enabledSubmitButton = page.find('button[type="submit"]'); + expect(enabledSubmitButton.prop('disabled')).toBe(true); + }); +}); diff --git a/public/app/containers/ManageDashboards/FolderSettings.tsx b/public/app/containers/ManageDashboards/FolderSettings.tsx new file mode 100644 index 00000000000..ef3377622df --- /dev/null +++ b/public/app/containers/ManageDashboards/FolderSettings.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { inject, observer } from 'mobx-react'; +import { toJS } from 'mobx'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import IContainerProps from 'app/containers/IContainerProps'; +import { getSnapshot } from 'mobx-state-tree'; +import appEvents from 'app/core/app_events'; + +@inject('nav', 'folder', 'view') +@observer +export class FolderSettings extends React.Component { + formSnapshot: any; + dashboard: any; + + constructor(props) { + super(props); + this.loadStore(); + } + + loadStore() { + const { nav, folder, view } = this.props; + + return folder.load(view.routeParams.get('slug') as string).then(res => { + this.formSnapshot = getSnapshot(folder); + this.dashboard = res.dashboard; + + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); + }); + } + + onTitleChange(evt) { + this.props.folder.setTitle(this.getFormSnapshot().folder.title, evt.target.value); + } + + getFormSnapshot() { + if (!this.formSnapshot) { + this.formSnapshot = getSnapshot(this.props.folder); + } + + return this.formSnapshot; + } + + save(evt) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + const { nav, folder, view } = this.props; + + folder + .saveFolder(this.dashboard, { overwrite: false }) + .then(newUrl => { + view.updatePathAndQuery(newUrl, '', ''); + + appEvents.emit('dashboard-saved'); + appEvents.emit('alert-success', ['Folder saved']); + }) + .then(() => { + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); + }) + .catch(this.handleSaveFolderError); + } + + delete(evt) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + const { folder, view } = this.props; + const title = folder.folder.title; + + appEvents.emit('confirm-modal', { + title: 'Delete', + text: `Do you want to delete this folder and all its dashboards?`, + icon: 'fa-trash', + yesText: 'Delete', + onConfirm: () => { + return this.props.folder.deleteFolder().then(() => { + appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]); + view.updatePathAndQuery('dashboards', '', ''); + }); + }, + }); + } + + handleSaveFolderError(err) { + if (err.data && err.data.status === 'version-mismatch') { + err.isHandled = true; + + appEvents.emit('confirm-modal', { + title: 'Conflict', + text: 'Someone else has updated this folder.', + text2: 'Would you still like to save this folder?', + yesText: 'Save & Overwrite', + icon: 'fa-warning', + onConfirm: () => { + this.props.folder.saveFolder(this.dashboard, { overwrite: true }); + }, + }); + } + + if (err.data && err.data.status === 'name-exists') { + err.isHandled = true; + + appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']); + } + } + + render() { + const { nav, folder } = this.props; + + if (!folder.folder || !nav.main) { + return

Loading

; + } + + return ( +
+ +
+

Folder Settings

+ +
+
+
+ + +
+
+ + +
+ +
+
+
+ ); + } +} diff --git a/public/app/containers/ServerStats/ServerStats.jest.tsx b/public/app/containers/ServerStats/ServerStats.jest.tsx index ae5cb736ad6..a329a47527d 100644 --- a/public/app/containers/ServerStats/ServerStats.jest.tsx +++ b/public/app/containers/ServerStats/ServerStats.jest.tsx @@ -20,7 +20,7 @@ describe('ServerStats', () => { } ); - const page = renderer.create(); + const page = renderer.create(); setTimeout(() => { expect(page.toJSON()).toMatchSnapshot(); diff --git a/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap b/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap index 7e6f1d11c27..eac793ca2ca 100644 --- a/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap +++ b/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap @@ -41,9 +41,9 @@ exports[`ServerStats Should render table with stats 1`] = ` /> - {main.children.map(SelectOption)} + {main.children.map((navItem: NavModelItem) => { + if (navItem.hideFromTabs) { + // TODO: Rename hideFromTabs => hideFromNav + return null; + } + return ( + + ); + })}
); -} +}; -function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) { - return
    {main.children.map(TabItem)}
; -} +const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => { + return ( +
+ ); +}; + +const Navigation = ({ main }: { main: NavModelItem }) => { + return ( + + ); +}; + +@observer export default class PageHeader extends React.Component { constructor(props) { super(props); } - renderBreadcrumb(breadcrumbs) { + shouldComponentUpdate() { + //Hack to re-render on changed props from angular with the @observer decorator + return true; + } + + renderTitle(title: string, breadcrumbs: any[]) { + if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { + return null; + } + + if (!breadcrumbs || breadcrumbs.length === 0) { + return

{title}

; + } + const breadcrumbsResult = []; for (let i = 0; i < breadcrumbs.length; i++) { const bc = breadcrumbs[i]; @@ -99,7 +114,9 @@ export default class PageHeader extends React.Component { breadcrumbsResult.push( / {bc.title}); } } - return breadcrumbsResult; + breadcrumbsResult.push( / {title}); + + return

{breadcrumbsResult}

; } renderHeaderTitle(main) { @@ -111,11 +128,7 @@ export default class PageHeader extends React.Component {
- {main.text &&

{main.text}

} - {main.breadcrumbs && - main.breadcrumbs.length > 0 && ( -

{this.renderBreadcrumb(main.breadcrumbs)}

- )} + {this.renderTitle(main.text, main.breadcrumbs)} {main.subTitle &&
{main.subTitle}
} {main.subType && (
@@ -135,12 +148,14 @@ export default class PageHeader extends React.Component { return null; } + const main = toJS(model.main); // Convert to JS if its a mobx observable + return (
- {this.renderHeaderTitle(model.main)} - {model.main.children && } + {this.renderHeaderTitle(main)} + {main.children && }
diff --git a/public/app/core/components/Permissions/DashboardPermissions.tsx b/public/app/core/components/Permissions/DashboardPermissions.tsx new file mode 100644 index 00000000000..2636b0d4db4 --- /dev/null +++ b/public/app/core/components/Permissions/DashboardPermissions.tsx @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import { store } from 'app/stores/store'; +import Permissions from 'app/core/components/Permissions/Permissions'; +import Tooltip from 'app/core/components/Tooltip/Tooltip'; +import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo'; + +export interface IProps { + dashboardId: number; + folderId: number; + folderTitle: string; + folderSlug: string; + backendSrv: any; +} + +class DashboardPermissions extends Component { + permissions: any; + + constructor(props) { + super(props); + this.permissions = store.permissions; + } + + render() { + const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props; + + return ( +
+
+

Permissions

+ + + +
+ +
+ ); + } +} + +export default DashboardPermissions; diff --git a/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx b/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx new file mode 100644 index 00000000000..db45714136e --- /dev/null +++ b/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; +import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; + +export interface IProps { + item: any; +} + +export default class DisabledPermissionListItem extends Component { + render() { + const { item } = this.props; + + return ( + + + + + + + Can + +
+ {}} + value={item.permission} + disabled={true} + className={'gf-form-input--form-dropdown-right'} + /> +
+ + + + + + ); + } +} diff --git a/public/app/core/components/Permissions/FolderInfo.ts b/public/app/core/components/Permissions/FolderInfo.ts new file mode 100644 index 00000000000..67ebb753df0 --- /dev/null +++ b/public/app/core/components/Permissions/FolderInfo.ts @@ -0,0 +1,5 @@ +export interface FolderInfo { + title: string; + id: number; + slug: string; +} diff --git a/public/app/core/components/Permissions/Permissions.jest.tsx b/public/app/core/components/Permissions/Permissions.jest.tsx new file mode 100644 index 00000000000..0a608ee5842 --- /dev/null +++ b/public/app/core/components/Permissions/Permissions.jest.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Permissions from './Permissions'; +import { RootStore } from 'app/stores/RootStore/RootStore'; +import { backendSrv } from 'test/mocks/common'; +import { shallow } from 'enzyme'; + +describe('Permissions', () => { + let wrapper; + + beforeAll(() => { + backendSrv.get.mockReturnValue( + Promise.resolve([ + { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' }, + { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' }, + { + id: 4, + dashboardId: 1, + userId: 2, + userLogin: 'danlimerick', + userEmail: 'dan.limerick@gmail.com', + permission: 4, + permissionName: 'Admin', + }, + ]) + ); + + backendSrv.post = jest.fn(); + + const store = RootStore.create( + {}, + { + backendSrv: backendSrv, + } + ); + + wrapper = shallow(); + return wrapper.instance().loadStore(1, true); + }); + + describe('when permission for a user is added', () => { + it('should save permission to db', () => { + const userItem = { + id: 2, + login: 'user2', + }; + + wrapper + .instance() + .userPicked(userItem) + .then(() => { + expect(backendSrv.post.mock.calls.length).toBe(1); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + }); + }); + }); + + describe('when permission for team is added', () => { + it('should save permission to db', () => { + const teamItem = { + id: 2, + name: 'ug1', + }; + + wrapper + .instance() + .teamPicked(teamItem) + .then(() => { + expect(backendSrv.post.mock.calls.length).toBe(1); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + }); + }); + }); +}); diff --git a/public/app/core/components/Permissions/Permissions.tsx b/public/app/core/components/Permissions/Permissions.tsx new file mode 100644 index 00000000000..647a9060b0d --- /dev/null +++ b/public/app/core/components/Permissions/Permissions.tsx @@ -0,0 +1,161 @@ +import React, { Component } from 'react'; +import PermissionsList from './PermissionsList'; +import { observer } from 'mobx-react'; +import UserPicker, { User } from 'app/core/components/Picker/UserPicker'; +import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker'; +import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore'; +import { FolderInfo } from './FolderInfo'; + +export interface DashboardAcl { + id?: number; + dashboardId?: number; + userId?: number; + userLogin?: string; + userEmail?: string; + teamId?: number; + team?: string; + permission?: number; + permissionName?: string; + role?: string; + icon?: string; + nameHtml?: string; + inherited?: boolean; + sortName?: string; + sortRank?: number; +} + +export interface IProps { + dashboardId: number; + folderInfo?: FolderInfo; + permissions?: any; + isFolder: boolean; + backendSrv: any; +} + +@observer +class Permissions extends Component { + constructor(props) { + super(props); + const { dashboardId, isFolder, folderInfo } = this.props; + this.permissionChanged = this.permissionChanged.bind(this); + this.typeChanged = this.typeChanged.bind(this); + this.removeItem = this.removeItem.bind(this); + this.userPicked = this.userPicked.bind(this); + this.teamPicked = this.teamPicked.bind(this); + this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0); + } + + loadStore(dashboardId, isFolder, isInRoot = false) { + return this.props.permissions.load(dashboardId, isFolder, isInRoot); + } + + permissionChanged(index: number, permission: number, permissionName: string) { + const { permissions } = this.props; + permissions.updatePermissionOnIndex(index, permission, permissionName); + } + + removeItem(index: number) { + const { permissions } = this.props; + permissions.removeStoreItem(index); + } + + resetNewType() { + const { permissions } = this.props; + permissions.resetNewType(); + } + + typeChanged(evt) { + const { value } = evt.target; + const { permissions, dashboardId } = this.props; + + if (value === 'Viewer' || value === 'Editor') { + permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId); + this.resetNewType(); + return; + } + + permissions.setNewType(value); + } + + userPicked(user: User) { + const { permissions, dashboardId } = this.props; + return permissions.addStoreItem({ + userId: user.id, + userLogin: user.login, + permission: 1, + dashboardId: dashboardId, + }); + } + + teamPicked(team: Team) { + const { permissions, dashboardId } = this.props; + return permissions.addStoreItem({ + teamId: team.id, + team: team.name, + permission: 1, + dashboardId: dashboardId, + }); + } + + render() { + const { permissions, folderInfo, backendSrv } = this.props; + + return ( +
+ +
+
+
Add Permission For
+
+
+
+ +
+
+ + {permissions.newType === 'User' ? ( +
+ +
+ ) : null} + + {permissions.newType === 'Group' ? ( +
+ +
+ ) : null} +
+
+ {permissions.error ? ( +
+ + + {permissions.error} + +
+ ) : null} +
+
+ ); + } +} + +export default Permissions; diff --git a/public/app/core/components/Permissions/PermissionsInfo.tsx b/public/app/core/components/Permissions/PermissionsInfo.tsx new file mode 100644 index 00000000000..9791e344085 --- /dev/null +++ b/public/app/core/components/Permissions/PermissionsInfo.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default () => { + return ( +
+
What are Permissions?
+

+ An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be + assigned permissions for a folder or for a single dashboard. +

+
+ ); +}; diff --git a/public/app/core/components/Permissions/PermissionsList.tsx b/public/app/core/components/Permissions/PermissionsList.tsx new file mode 100644 index 00000000000..b215dad2391 --- /dev/null +++ b/public/app/core/components/Permissions/PermissionsList.tsx @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PermissionsListItem from './PermissionsListItem'; +import DisabledPermissionsListItem from './DisabledPermissionsListItem'; +import { observer } from 'mobx-react'; +import { FolderInfo } from './FolderInfo'; + +export interface IProps { + permissions: any[]; + removeItem: any; + permissionChanged: any; + fetching: boolean; + folderInfo?: FolderInfo; +} + +@observer +class PermissionsList extends Component { + render() { + const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props; + + return ( + + + Admin Role', + permission: 4, + icon: 'fa fa-fw fa-street-view', + }} + /> + {permissions.map((item, idx) => { + return ( + + ); + })} + {fetching === true && permissions.length < 1 ? ( + + + + ) : null} + + {fetching === false && permissions.length < 1 ? ( + + + + ) : null} + +
+ Loading permissions... +
+ No permissions are set. Will only be accessible by admins. +
+ ); + } +} + +export default PermissionsList; diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx new file mode 100644 index 00000000000..291ee20e157 --- /dev/null +++ b/public/app/core/components/Permissions/PermissionsListItem.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; +import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; + +const setClassNameHelper = inherited => { + return inherited ? 'gf-form-disabled' : ''; +}; + +export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => { + const handleRemoveItem = evt => { + evt.preventDefault(); + removeItem(itemIndex); + }; + + const handleChangePermission = permissionOption => { + permissionChanged(itemIndex, permissionOption.value, permissionOption.label); + }; + + const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0; + + return ( + + + + + + + {item.inherited && + folderInfo && ( + + Inherited from folder{' '} + + {folderInfo.title} + {' '} + + )} + {inheritedFromRoot && Default Permission} + + Can + +
+ +
+ + + {!item.inherited ? ( + + + + ) : ( + + )} + + + ); +}); diff --git a/public/app/core/components/Picker/DescriptionOption.tsx b/public/app/core/components/Picker/DescriptionOption.tsx new file mode 100644 index 00000000000..12a1fdd9163 --- /dev/null +++ b/public/app/core/components/Picker/DescriptionOption.tsx @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; + +export interface IProps { + onSelect: any; + onFocus: any; + option: any; + isFocused: any; + className: any; +} + +class DescriptionOption extends Component { + constructor(props) { + super(props); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseEnter = this.handleMouseEnter.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + } + + handleMouseDown(event) { + event.preventDefault(); + event.stopPropagation(); + this.props.onSelect(this.props.option, event); + } + + handleMouseEnter(event) { + this.props.onFocus(this.props.option, event); + } + + handleMouseMove(event) { + if (this.props.isFocused) { + return; + } + this.props.onFocus(this.props.option, event); + } + + render() { + const { option, children, className } = this.props; + return ( +