diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b91161d966c..b02247edad1 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1110,6 +1110,10 @@ export interface FeatureToggles { */ alertingTriage?: boolean; /** + * When manageAlerts=false, disable data source API for alert rules + */ + alertingDisableDSAPIWithManageAlerts?: boolean; + /** * Enables the Graphite data source full backend mode * @default false */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d8a46601ad6..26de8011335 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1833,6 +1833,13 @@ var ( HideFromDocs: true, Expression: "false", }, + { + Name: "alertingDisableDSAPIWithManageAlerts", + Description: "When manageAlerts=false, disable data source API for alert rules", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + }, { Name: "graphiteBackendMode", Description: "Enables the Graphite data source full backend mode", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 3fbc019bff1..1622f6e84b2 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -250,6 +250,7 @@ newClickhouseConfigPageDesign,privatePreview,@grafana/partner-datasources,false, teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,false interactiveLearning,preview,@grafana/pathfinder,false,false,false alertingTriage,experimental,@grafana/alerting-squad,false,false,false +alertingDisableDSAPIWithManageAlerts,experimental,@grafana/alerting-squad,false,false,false graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 020e0bce893..d066386e2bf 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -718,6 +718,10 @@ const ( // Enables the alerting triage feature FlagAlertingTriage = "alertingTriage" + // FlagAlertingDisableDSAPIWithManageAlerts + // When manageAlerts=false, disable data source API for alert rules + FlagAlertingDisableDSAPIWithManageAlerts = "alertingDisableDSAPIWithManageAlerts" + // FlagGraphiteBackendMode // Enables the Graphite data source full backend mode FlagGraphiteBackendMode = "graphiteBackendMode" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 866f8a78881..99ad9e8d7bc 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -195,6 +195,19 @@ "codeowner": "@grafana/alerting-squad" } }, + { + "metadata": { + "name": "alertingDisableDSAPIWithManageAlerts", + "resourceVersion": "1766143488044", + "creationTimestamp": "2025-12-19T11:24:48Z" + }, + "spec": { + "description": "When manageAlerts=false, disable data source API for alert rules", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingDisableSendAlertsExternal", diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index eefbb6dea30..653e44af3d4 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -137,7 +137,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { // Register endpoints for proxying to Cortex Ruler-compatible backends. api.RegisterRulerApiEndpoints(NewForkingRuler( api.DatasourceCache, - NewLotexRuler(proxy, logger), + NewLotexRuler(proxy, logger, api.FeatureManager, api.Cfg.DefaultDatasourceManageAlertsUIToggle), &RulerSrv{ conditionValidator: api.ConditionValidator, QuotaService: api.QuotaService, diff --git a/pkg/services/ngalert/api/lotex_ruler.go b/pkg/services/ngalert/api/lotex_ruler.go index c058a554570..916357f7f51 100644 --- a/pkg/services/ngalert/api/lotex_ruler.go +++ b/pkg/services/ngalert/api/lotex_ruler.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "errors" "fmt" "io" "net/http" @@ -10,9 +11,11 @@ import ( "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/web" ) @@ -37,29 +40,48 @@ var subtypeToPrefix = map[string]string{ Mimir: mimirPrefix, } +var ( + errManageAlertsDisabled = errutil.Forbidden( + "alerting.manageAlertsDisabled", + errutil.WithPublicMessage("Alert management is disabled for this datasource"), + ).Errorf("manage alerts is disabled") +) + // The requester is primarily used for testing purposes, allowing us to inject a different implementation of withReq. type requester interface { withReq(ctx *contextmodel.ReqContext, method string, u *url.URL, body io.Reader, extractor func(*response.NormalResponse) (any, error), headers map[string]string) response.Response } type LotexRuler struct { - log log.Logger + log log.Logger + features featuremgmt.FeatureToggles + defaultManageAlertsEnabled bool *AlertingProxy requester requester } -func NewLotexRuler(proxy *AlertingProxy, log log.Logger) *LotexRuler { +func NewLotexRuler(proxy *AlertingProxy, log log.Logger, features featuremgmt.FeatureToggles, defaultManageAlertsEnabled bool) *LotexRuler { return &LotexRuler{ - log: log, - AlertingProxy: proxy, - requester: proxy, + log: log, + features: features, + defaultManageAlertsEnabled: defaultManageAlertsEnabled, + AlertingProxy: proxy, + requester: proxy, } } +// handleValidateError returns appropriate error response: 403 for manageAlerts, 500 for others +func handleValidateError(err error) response.Response { + if errors.Is(err, errManageAlertsDisabled) { + return errorToResponse(err) + } + return ErrResp(500, err, "") +} + func (r *LotexRuler) RouteDeleteNamespaceRulesConfig(ctx *contextmodel.ReqContext, namespace string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { - return ErrResp(500, err, "") + return handleValidateError(err) } finalNamespace, err := getRulesNamespaceParam(ctx, namespace) @@ -83,7 +105,7 @@ func (r *LotexRuler) RouteDeleteNamespaceRulesConfig(ctx *contextmodel.ReqContex func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext, namespace string, group string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { - return ErrResp(500, err, "") + return handleValidateError(err) } finalNamespace, err := getRulesNamespaceParam(ctx, namespace) @@ -117,7 +139,7 @@ func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext, na func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext, namespace string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { - return ErrResp(500, err, "") + return handleValidateError(err) } finalNamespace, err := getRulesNamespaceParam(ctx, namespace) @@ -145,7 +167,7 @@ func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext, func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext, namespace string, group string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { - return ErrResp(500, err, "") + return handleValidateError(err) } finalNamespace, err := getRulesNamespaceParam(ctx, namespace) @@ -179,7 +201,7 @@ func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext, name func (r *LotexRuler) RouteGetRulesConfig(ctx *contextmodel.ReqContext) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { - return ErrResp(500, err, "") + return handleValidateError(err) } return r.requester.withReq( @@ -198,7 +220,7 @@ func (r *LotexRuler) RouteGetRulesConfig(ctx *contextmodel.ReqContext) response. func (r *LotexRuler) RoutePostNameRulesConfig(ctx *contextmodel.ReqContext, conf apimodels.PostableRuleGroupConfig, ns string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { - return ErrResp(500, err, "") + return handleValidateError(err) } yml, err := yaml.Marshal(conf) if err != nil { @@ -225,6 +247,21 @@ func (r *LotexRuler) validateAndGetPrefix(ctx *contextmodel.ReqContext) (string, return "", err } + // nolint:staticcheck // not yet migrated to OpenFeature + if r.features.IsEnabledGlobally(featuremgmt.FlagAlertingDisableDSAPIWithManageAlerts) { + // Check if manageAlerts is disabled for this datasource + // Use server default if not explicitly set in datasource jsonData + manageAlerts := r.defaultManageAlertsEnabled + if ds.JsonData != nil { + if manageAlertsVal, ok := ds.JsonData.CheckGet("manageAlerts"); ok { + manageAlerts = manageAlertsVal.MustBool(r.defaultManageAlertsEnabled) + } + } + if !manageAlerts { + return "", errManageAlertsDisabled + } + } + // Validate URL if ds.URL == "" { return "", fmt.Errorf("URL for this data source is empty") diff --git a/pkg/services/ngalert/api/lotex_ruler_test.go b/pkg/services/ngalert/api/lotex_ruler_test.go index 484a92541ed..d1ee318c83b 100644 --- a/pkg/services/ngalert/api/lotex_ruler_test.go +++ b/pkg/services/ngalert/api/lotex_ruler_test.go @@ -13,10 +13,12 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" ) @@ -117,13 +119,75 @@ func TestLotexRuler_ValidateAndGetPrefix(t *testing.T) { datasourceCache: fakeCacheService{datasource: &datasources.DataSource{URL: "http://azp.com", Type: datasources.DS_AZURE_PROMETHEUS}}, expected: "/config/v1/rules", }, + { + name: "with manageAlerts explicitly set to false (feature toggle enabled)", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasourceCache: fakeCacheService{datasource: createDatasourceWithManageAlerts("http://loki.com", datasources.DS_LOKI, false)}, + err: errManageAlertsDisabled, + }, + { + name: "with manageAlerts explicitly set to true (feature toggle enabled)", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasourceCache: fakeCacheService{datasource: createDatasourceWithManageAlerts("http://loki.com", datasources.DS_LOKI, true)}, + expected: "/api/prom/rules", + }, + { + name: "with manageAlerts undefined (feature toggle enabled)", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasourceCache: fakeCacheService{datasource: &datasources.DataSource{URL: "http://loki.com", Type: datasources.DS_LOKI, JsonData: simplejson.New()}}, + expected: "/api/prom/rules", + }, + { + name: "with JsonData nil (feature toggle enabled)", + namedParams: map[string]string{":DatasourceUID": "d164"}, + datasourceCache: fakeCacheService{datasource: &datasources.DataSource{URL: "http://loki.com", Type: datasources.DS_LOKI, JsonData: nil}}, + expected: "/api/prom/rules", + }, } + features := featuremgmt.WithFeatures(featuremgmt.FlagAlertingDisableDSAPIWithManageAlerts) + + t.Run("with server default manage alerts = false", func(t *testing.T) { + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: &datasources.DataSource{URL: "http://loki.com", Type: datasources.DS_LOKI}}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: features, defaultManageAlertsEnabled: false} + httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com", nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, map[string]string{":DatasourceUID": "d164"})}} + + prefix, err := ruler.validateAndGetPrefix(ctx) + require.Empty(t, prefix) + require.ErrorIs(t, err, errManageAlertsDisabled) + }) + + t.Run("with server default false but datasource manageAlerts true should allow", func(t *testing.T) { + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: createDatasourceWithManageAlerts("http://loki.com", datasources.DS_LOKI, true)}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: features, defaultManageAlertsEnabled: false} + httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com", nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, map[string]string{":DatasourceUID": "d164"})}} + + prefix, err := ruler.validateAndGetPrefix(ctx) + require.Equal(t, "/api/prom/rules", prefix) + require.NoError(t, err) + }) + + t.Run("with feature toggle disabled, manageAlerts false should allow", func(t *testing.T) { + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: createDatasourceWithManageAlerts("http://loki.com", datasources.DS_LOKI, false)}}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: featuremgmt.WithFeatures(), defaultManageAlertsEnabled: true} + httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com", nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, map[string]string{":DatasourceUID": "d164"})}} + + prefix, err := ruler.validateAndGetPrefix(ctx) + require.Equal(t, "/api/prom/rules", prefix) + require.NoError(t, err) + }) + for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { // Setup Proxy. proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: tt.datasourceCache}} - ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger()} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: features, defaultManageAlertsEnabled: true} // Setup request context. httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com"+tt.urlParams, nil) @@ -160,6 +224,16 @@ func (f fakeCacheService) GetDatasourceByUID(ctx context.Context, datasourceUID return f.datasource, nil } +func createDatasourceWithManageAlerts(url, dsType string, manageAlerts bool) *datasources.DataSource { + data := simplejson.New() + data.Set("manageAlerts", manageAlerts) + return &datasources.DataSource{ + URL: url, + Type: dsType, + JsonData: data, + } +} + func TestLotexRuler_RouteDeleteNamespaceRulesConfig(t *testing.T) { tc := []struct { name string @@ -207,7 +281,7 @@ func TestLotexRuler_RouteDeleteNamespaceRulesConfig(t *testing.T) { // Setup Proxy. proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} - ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: featuremgmt.WithFeatures(), defaultManageAlertsEnabled: true, requester: &requestMock} // Setup request context. httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) @@ -269,7 +343,7 @@ func TestLotexRuler_RouteDeleteRuleGroupConfig(t *testing.T) { // Setup Proxy. proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} - ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: featuremgmt.WithFeatures(), defaultManageAlertsEnabled: true, requester: &requestMock} // Setup request context. httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) @@ -329,7 +403,7 @@ func TestLotexRuler_RouteGetNamespaceRulesConfig(t *testing.T) { // Setup Proxy. proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} - ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: featuremgmt.WithFeatures(), defaultManageAlertsEnabled: true, requester: &requestMock} // Setup request context. httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil) @@ -391,7 +465,7 @@ func TestLotexRuler_RouteGetRulegGroupConfig(t *testing.T) { // Setup Proxy. proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: fakeCacheService{datasource: tt.datasource}}} - ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), requester: &requestMock} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger(), features: featuremgmt.WithFeatures(), defaultManageAlertsEnabled: true, requester: &requestMock} // Setup request context. httpReq, err := http.NewRequest(http.MethodGet, tt.datasource.URL+tt.urlParams, nil)