From df25e9197e5f4d39acaa8e28c8a97ad5b342f98c Mon Sep 17 00:00:00 2001 From: Fayzal Ghantiwala <114010985+fayzal-g@users.noreply.github.com> Date: Thu, 2 May 2024 15:24:59 +0100 Subject: [PATCH] Alerting: Get grafana-managed alert rule by UID (#86845) * Add auth checks and test * Check user is authorized to view rule and add tests * Change naming * Update Swagger params * Update auth test and swagger gen * Update swagger gen * Change response to GettableExtendedRuleNode * openapi3-gen * Update tests with refactors models pkg --- pkg/services/ngalert/api/api_ruler.go | 23 ++++++ pkg/services/ngalert/api/api_ruler_test.go | 73 +++++++++++++++++++ pkg/services/ngalert/api/authorization.go | 5 ++ .../ngalert/api/authorization_test.go | 2 +- pkg/services/ngalert/api/forking_ruler.go | 4 + .../ngalert/api/generated_base_api_ruler.go | 18 +++++ pkg/services/ngalert/api/tooling/api.json | 3 +- .../api/tooling/definitions/cortex-ruler.go | 18 +++++ pkg/services/ngalert/api/tooling/post.json | 44 ++++++++++- pkg/services/ngalert/api/tooling/spec.json | 44 ++++++++++- public/api-merged.json | 3 +- public/openapi3.json | 3 +- 12 files changed, 227 insertions(+), 13 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 53f1e1464cb..ee0bfa0db76 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -263,6 +263,29 @@ func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Res return response.JSON(http.StatusOK, result) } +// RouteGetRuleByUID returns the alert rule with the given UID +func (srv RulerSrv) RouteGetRuleByUID(c *contextmodel.ReqContext, ruleUID string) response.Response { + ctx := c.Req.Context() + orgID := c.SignedInUser.GetOrgID() + + rule, err := srv.getAuthorizedRuleByUid(ctx, c, ruleUID) + if err != nil { + if errors.Is(err, ngmodels.ErrAlertRuleNotFound) { + return response.Empty(http.StatusNotFound) + } + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule by UID", err) + } + + provenance, err := srv.provenanceStore.GetProvenance(ctx, &rule, orgID) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule provenance", err) + } + + result := toGettableExtendedRuleNode(rule, map[string]ngmodels.Provenance{rule.ResourceID(): provenance}) + + return response.JSON(http.StatusOK, result) +} + func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response { namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 99c109c00ca..9401acb5f47 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -318,6 +318,79 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { }) } +func TestRouteGetRuleByUID(t *testing.T) { + t.Run("rule is successfully fetched with the correct UID", func(t *testing.T) { + orgID := rand.Int63() + folder := randFolder() + ruleStore := fakes.NewRuleStore(t) + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + groupKey := models.GenerateGroupKey(orgID) + groupKey.NamespaceUID = folder.UID + gen := models.RuleGen.With(models.RuleGen.WithGroupKey(groupKey)) + + createdRules := gen.With(gen.WithUniqueGroupIndex(), gen.WithUniqueID()).GenerateManyRef(3) + require.Len(t, createdRules, 3) + ruleStore.PutRule(context.Background(), createdRules...) + + perms := createPermissionsForRules(createdRules, orgID) + req := createRequestContextWithPerms(orgID, perms, nil) + + expectedRule := createdRules[1] + response := createService(ruleStore).RouteGetRuleByUID(req, expectedRule.UID) + + require.Equal(t, http.StatusOK, response.Status()) + result := &apimodels.GettableExtendedRuleNode{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.NotNil(t, result) + + require.Equal(t, expectedRule.UID, result.GrafanaManagedAlert.UID) + require.Equal(t, expectedRule.RuleGroup, result.GrafanaManagedAlert.RuleGroup) + require.Equal(t, expectedRule.Title, result.GrafanaManagedAlert.Title) + }) + + t.Run("error when fetching rule with non-existent UID", func(t *testing.T) { + orgID := rand.Int63() + folder := randFolder() + ruleStore := fakes.NewRuleStore(t) + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + groupKey := models.GenerateGroupKey(orgID) + groupKey.NamespaceUID = folder.UID + gen := models.RuleGen.With(models.RuleGen.WithGroupKey(groupKey)) + + createdRules := gen.With(gen.WithUniqueGroupIndex(), gen.WithUniqueID()).GenerateManyRef(3) + require.Len(t, createdRules, 3) + ruleStore.PutRule(context.Background(), createdRules...) + + perms := createPermissionsForRules(createdRules, orgID) + req := createRequestContextWithPerms(orgID, perms, nil) + response := createService(ruleStore).RouteGetRuleByUID(req, "foobar") + + require.Equal(t, http.StatusNotFound, response.Status()) + }) + + t.Run("error due to user not being authorized to view a rule in the group", func(t *testing.T) { + orgID := rand.Int63() + folder := randFolder() + ruleStore := fakes.NewRuleStore(t) + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + groupKey := models.GenerateGroupKey(orgID) + groupKey.NamespaceUID = folder.UID + gen := models.RuleGen.With(models.RuleGen.WithGroupKey(groupKey)) + + authorizedRule := gen.With(gen.WithUniqueGroupIndex()).Generate() + ruleStore.PutRule(context.Background(), &authorizedRule) + + unauthorizedRule := gen.With(gen.WithUniqueGroupIndex()).Generate() + ruleStore.PutRule(context.Background(), &unauthorizedRule) + + perms := createPermissionsForRules([]*models.AlertRule{&authorizedRule}, orgID) + req := createRequestContextWithPerms(orgID, perms, nil) + response := createService(ruleStore).RouteGetRuleByUID(req, authorizedRule.UID) + + require.Equal(t, http.StatusForbidden, response.Status()) + }) +} + func TestRouteGetRulesConfig(t *testing.T) { gen := models.RuleGen t.Run("fine-grained access is enabled", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index b97b0c9599d..c6eef344de6 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -40,6 +40,11 @@ func (api *API) authorize(method, path string) web.Handler { case http.MethodGet + "/api/ruler/grafana/api/v1/rules", http.MethodGet + "/api/ruler/grafana/api/v1/export/rules": eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + case http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}": + eval = ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalPermission(dashboards.ActionFoldersRead), + ) case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}/export": scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace")) // more granular permissions are enforced by the handler via "authorizeRuleChanges" diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index a4603e2c06a..b16305cfd78 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -40,7 +40,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 58) + require.Len(t, paths, 59) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/forking_ruler.go b/pkg/services/ngalert/api/forking_ruler.go index 51d8a2a4155..e1b9f2e570c 100644 --- a/pkg/services/ngalert/api/forking_ruler.go +++ b/pkg/services/ngalert/api/forking_ruler.go @@ -93,6 +93,10 @@ func (f *RulerApiHandler) handleRouteGetGrafanaRulesConfig(ctx *contextmodel.Req return f.GrafanaRuler.RouteGetRulesConfig(ctx) } +func (f *RulerApiHandler) handleRouteGetRuleByUID(ctx *contextmodel.ReqContext, ruleUID string) response.Response { + return f.GrafanaRuler.RouteGetRuleByUID(ctx, ruleUID) +} + func (f *RulerApiHandler) handleRoutePostNameGrafanaRulesConfig(ctx *contextmodel.ReqContext, conf apimodels.PostableRuleGroupConfig, namespace string) response.Response { payloadType := conf.Type() if payloadType != apimodels.GrafanaBackend { diff --git a/pkg/services/ngalert/api/generated_base_api_ruler.go b/pkg/services/ngalert/api/generated_base_api_ruler.go index 6c496e31375..0e06ea76f9a 100644 --- a/pkg/services/ngalert/api/generated_base_api_ruler.go +++ b/pkg/services/ngalert/api/generated_base_api_ruler.go @@ -28,6 +28,7 @@ type RulerApi interface { RouteGetGrafanaRulesConfig(*contextmodel.ReqContext) response.Response RouteGetNamespaceGrafanaRulesConfig(*contextmodel.ReqContext) response.Response RouteGetNamespaceRulesConfig(*contextmodel.ReqContext) response.Response + RouteGetRuleByUID(*contextmodel.ReqContext) response.Response RouteGetRulegGroupConfig(*contextmodel.ReqContext) response.Response RouteGetRulesConfig(*contextmodel.ReqContext) response.Response RouteGetRulesForExport(*contextmodel.ReqContext) response.Response @@ -80,6 +81,11 @@ func (f *RulerApiHandler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqCont namespaceParam := web.Params(ctx.Req)[":Namespace"] return f.handleRouteGetNamespaceRulesConfig(ctx, datasourceUIDParam, namespaceParam) } +func (f *RulerApiHandler) RouteGetRuleByUID(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + ruleUIDParam := web.Params(ctx.Req)[":RuleUID"] + return f.handleRouteGetRuleByUID(ctx, ruleUIDParam) +} func (f *RulerApiHandler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] @@ -225,6 +231,18 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) { m, ), ) + group.Get( + toMacaronPath("/api/ruler/grafana/api/v1/rule/{RuleUID}"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/ruler/grafana/api/v1/rule/{RuleUID}"), + metrics.Instrument( + http.MethodGet, + "/api/ruler/grafana/api/v1/rule/{RuleUID}", + api.Hooks.Wrap(srv.RouteGetRuleByUID), + m, + ), + ) group.Get( toMacaronPath("/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}"), requestmeta.SetOwner(requestmeta.TeamAlerting), diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 5e25f7b6702..7154995fea5 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -4546,7 +4546,6 @@ "type": "object" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -4608,6 +4607,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4807,7 +4807,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 55005b1a4f4..b1e7d145f33 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -8,6 +8,18 @@ import ( "github.com/prometheus/common/model" ) +// swagger:route Get /ruler/grafana/api/v1/rule/{RuleUID} ruler RouteGetRuleByUID +// +// Get rule by UID +// +// Produces: +// - application/json +// +// Responses: +// 202: GettableExtendedRuleNode +// 403: ForbiddenError +// 404: description: Not found. + // swagger:route Get /ruler/grafana/api/v1/rules ruler RouteGetGrafanaRulesConfig // // List rule groups @@ -206,6 +218,12 @@ type PathGetRulesParams struct { PanelID int64 } +// swagger:parameters RouteGetRuleByUID +type PathGetRuleByUIDParams struct { + // in: path + RuleUID string +} + // swagger:model type RuleGroupConfigResponse struct { GettableRuleGroupConfig diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 687d223c558..e17b66af824 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -4176,6 +4176,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -4211,7 +4212,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "UpdateRuleGroupResponse": { @@ -4545,6 +4546,7 @@ "type": "object" }, "gettableAlert": { + "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -4606,7 +4608,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4662,7 +4663,6 @@ "type": "array" }, "integration": { - "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -4806,6 +4806,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -6218,6 +6219,43 @@ ] } }, + "/ruler/grafana/api/v1/rule/{RuleUID}": { + "get": { + "description": "Get rule by UID", + "operationId": "RouteGetRuleByUID", + "parameters": [ + { + "in": "path", + "name": "RuleUID", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "GettableGrafanaRule", + "schema": { + "$ref": "#/definitions/GettableGrafanaRule" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": " Not found." + } + }, + "tags": [ + "ruler" + ] + } + }, "/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index c58984d9c4a..b7c49010b46 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1272,6 +1272,43 @@ } } }, + "/ruler/grafana/api/v1/rule/{RuleUID}": { + "get": { + "description": "Get rule by UID", + "produces": [ + "application/json" + ], + "tags": [ + "ruler" + ], + "operationId": "RouteGetRuleByUID", + "parameters": [ + { + "type": "string", + "name": "RuleUID", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "GettableGrafanaRule", + "schema": { + "$ref": "#/definitions/GettableGrafanaRule" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", @@ -7654,8 +7691,9 @@ } }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { "ForceQuery": { "type": "boolean" @@ -8025,6 +8063,7 @@ } }, "gettableAlert": { + "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -8088,7 +8127,6 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -8146,7 +8184,6 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { - "description": "Integration integration", "type": "object", "required": [ "name", @@ -8291,6 +8328,7 @@ } }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", diff --git a/public/api-merged.json b/public/api-merged.json index b62fb2d60c6..17717dfe901 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -21311,7 +21311,6 @@ } }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -21373,6 +21372,7 @@ } }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -21572,7 +21572,6 @@ } }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", diff --git a/public/openapi3.json b/public/openapi3.json index c541f6c501d..40a0399bda3 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -11947,7 +11947,6 @@ "type": "object" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/components/schemas/labelSet" @@ -12009,6 +12008,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -12208,7 +12208,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment",