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..5908c9a7aac --- /dev/null +++ b/pkg/api/annotations_test.go @@ -0,0 +1,271 @@ +package api + +import ( + "path/filepath" + "testing" + + "github.com/go-macaron/session" + "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" + macaron "gopkg.in/macaron.v1" + + . "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{&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 := &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 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 := &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 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 19b51681f32..9544889bfbc 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -317,8 +317,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/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/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/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index fab351fe836..ed2d797b7bf 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -54,7 +54,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, `; // Show edit icon only for users with at least Editor role - if (event.id && contextSrv.isEditor) { + if (event.id && dashboard.meta.canEdit) { header += ` diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index a4ddeec7777..a8b84c46271 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -666,7 +666,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) { return; } - if ((ranges.ctrlKey || ranges.metaKey) && contextSrv.isEditor) { + if ((ranges.ctrlKey || ranges.metaKey) && dashboard.meta.canEdit) { // Add annotation setTimeout(() => { eventManager.updateTime(ranges.xaxis); @@ -687,7 +687,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) { return; } - if ((pos.ctrlKey || pos.metaKey) && contextSrv.isEditor) { + if ((pos.ctrlKey || pos.metaKey) && dashboard.meta.canEdit) { // Skip if range selected (added in "plotselected" event handler) let isRangeSelection = pos.x !== pos.x1; if (!isRangeSelection) {