diff --git a/conf/defaults.ini b/conf/defaults.ini index 4f247e5121d..c9ef90d5f3d 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -948,7 +948,7 @@ session_duration = "15m" # Set the plugins that will receive AWS settings for each request (via plugin context) # By default this will include all Grafana Labs owned AWS plugins, or those that make use of AWS settings (ElasticSearch, Prometheus). -forward_settings_to_plugins = cloudwatch, grafana-athena-datasource, grafana-redshift-datasource, grafana-x-ray-datasource, grafana-timestream-datasource, grafana-iot-sitewise-datasource, grafana-iot-twinmaker-app, grafana-opensearch-datasource, aws-datasource-provisioner, elasticsearch, prometheus +forward_settings_to_plugins = cloudwatch, grafana-athena-datasource, grafana-redshift-datasource, grafana-x-ray-datasource, grafana-timestream-datasource, grafana-iot-sitewise-datasource, grafana-iot-twinmaker-app, grafana-opensearch-datasource, aws-datasource-provisioner, elasticsearch, prometheus, grafana-amazonprometheus-datasource, grafana-aurora-datasource #################################### Azure ############################### [azure] diff --git a/docs/sources/datasources/google-cloud-monitoring/query-editor/index.md b/docs/sources/datasources/google-cloud-monitoring/query-editor/index.md index 937ff627a37..87e1ee9d85b 100644 --- a/docs/sources/datasources/google-cloud-monitoring/query-editor/index.md +++ b/docs/sources/datasources/google-cloud-monitoring/query-editor/index.md @@ -66,8 +66,17 @@ The metrics query editor helps you select metrics, group and aggregate by labels 1. _(Optional)_ Use the plus and minus icons in the filter and group-by sections to add and remove filters or group-by clauses. Google Cloud Monitoring supports several metrics types, such as `GAUGE`, `DELTA,` and `CUMULATIVE`. -Each supports different aggregation options, such as reducers and aligners. -The metrics query editor lists available aggregation methods for a selected metric, and sets a default reducer and aligner when you select a metric. +Each supports different aggregation options, such as reducers and aligners. Additionally, metrics have specific value types that can be either scalar or a distribution. + +The metrics query editor lists available aggregation methods for a selected metric, and sets a default aggregation, reducer and aligner when you select a metric. + +In the case that the metric value type is a distribution, the aggregation will be set by default to the mean. For scalar value types, there is no aggregation by default. + +The various metrics are documented [here](https://cloud.google.com/monitoring/api/metrics_gcp) and further details on the kinds and types of metrics can be found [here](https://cloud.google.com/monitoring/api/v3/kinds-and-types). + +{{% admonition type="note" %}} +Distribution metrics are typically best visualized as either a heatmap or histogram. When visualizing in this way, aggregation is not necessary. However, for other visualization types, performance degradation may be observed when attempting to query distribution metrics that are not aggregated due to the number of potential buckets that can be returned. For more information on how to visualize distribution metrics refer to [this page](https://cloud.google.com/monitoring/charts/charting-distribution-metrics). +{{% /admonition %}} ### Apply a filter diff --git a/docs/sources/panels-visualizations/visualizations/logs/index.md b/docs/sources/panels-visualizations/visualizations/logs/index.md index c2d73352c8a..00f0163f55f 100644 --- a/docs/sources/panels-visualizations/visualizations/logs/index.md +++ b/docs/sources/panels-visualizations/visualizations/logs/index.md @@ -32,7 +32,7 @@ _Logs_ are structured records of events or messages generated by a system or app The logs visualization displays these records from data sources that support logs, such as Elastic, Influx, and Loki. The logs visualization has colored indicators of log status, as well as collapsible log events that help you analyze the information generated. -{{< figure src="/static/img/docs/v64/logs-panel.png" max-width="1025px" alt="Logs panel" >}} +![Logs visualization](/media/docs/grafana/panels-visualizations/screenshot-logs-v11.3.png) {{< docs/play title="Logs Panel" url="https://play.grafana.org/d/6NmftOxZz/" >}} @@ -46,7 +46,7 @@ The following video provides a walkthrough of creating a logs visualization. You ## Supported data formats -The logs visualization works best with log-type datasets such as queries from data sources like Loki, Elastic, and InlfuxDB. +The logs visualization works best with log-type datasets such as queries from data sources like Loki, Elastic, and InfluxDB. You can also build log-formatted data from other data sources as long as the first field is a time type followed by string, number, and time fields. The leading time field is used to sort and timestamp the logs and if the data contains other time-type fields, they’re included as elements of the logged record. @@ -66,27 +66,33 @@ To limit the number of log lines rendered in the visualization, you can use the ![Logs Example](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-logs-example.png 'Logs Example') -## Panel options - -{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} - ## Log level For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions](ref:log-levels). -## Log details +## Configuration options -Each log row has an extendable area with its labels and detected fields, for more robust interaction. Each field or label has a stats icon to display ad-hoc statistics in relation to all displayed logs. +{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} -### Display options +### Panel options + +{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} + +### Logs options Use these settings to refine your visualization: -- **Time -** Show or hide the time column. This is the timestamp associated with the log line as reported from the data source. -- **Unique labels -** Show or hide the unique labels column, which shows only non-common labels. -- **Common labels -** Show or hide the common labels. -- **Wrap lines -** Toggle line wrapping. -- **Prettify JSON -** Set this to `true` to pretty print all JSON logs. This setting does not affect logs in any format other than JSON. -- **Enable log details -** Toggle option to see the log details view for each log row. The default setting is true. -- **Deduplication -** Hides log messages that are duplicates of others shown according to your selected criteria. Choose from: **Exact** (ignoring ISO datetimes), **Numerical** (ignoring only those that differ by numbers such as IPs or latencies), or **Signatures** (removing successive lines with identical punctuation and white space). -- **Order -** Display results in descending or ascending time order. The default is **Descending**, showing the newest logs first. Set to **Ascending** to show the oldest log lines first. + + +| Option | Description | +| ------ | ----------- | +| Time | Show or hide the time column. This is the timestamp associated with the log line as reported from the data source. | +| Unique labels | Show or hide the unique labels column, which shows only non-common labels. | +| Common labels | Show or hide the common labels. | +| Wrap lines | Turn line wrapping on or off. | +| Prettify JSON | Toggle the switch on to pretty print all JSON logs. This setting does not affect logs in any format other than JSON. | +| Enable log details | Toggle the switch on to see an extendable area with log details including labels and detected fields. Each field or label has a stats icon to display ad-hoc statistics in relation to all displayed logs. The default setting is on. | +| Deduplication | Hide log messages that are duplicates of others shown, according to your selected criteria. Choose from:
  • **Exact** - Ignoring ISO datetimes.
  • **Numerical** - Ignoring only those that differ by numbers such as IPs or latencies.
  • **Signatures** - Removing successive lines with identical punctuation and white space.
| +| Order | Set whether to show results **Newest first** or **Oldest first**. | + + diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts index 26d425befa2..32bfcb14efc 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -3,8 +3,8 @@ import { config } from '@grafana/runtime'; import { SUGGESTIONS_LIMIT } from '../../../language_provider'; import { FUNCTIONS } from '../../../promql'; -import { getCompletions } from './completions'; -import { DataProvider, DataProviderParams } from './data_provider'; +import { filterMetricNames, getCompletions } from './completions'; +import { DataProvider, type DataProviderParams } from './data_provider'; import type { Situation } from './situation'; const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3']; @@ -41,6 +41,133 @@ afterEach(() => { jest.restoreAllMocks(); }); +describe('filterMetricNames', () => { + const sampleMetrics = [ + 'http_requests_total', + 'http_requests_failed', + 'node_cpu_seconds_total', + 'node_memory_usage_bytes', + 'very_long_metric_name_with_many_underscores_and_detailed_description', + 'metric_name_1_with_extra_terms_included', + ]; + + describe('empty input', () => { + it('should return all metrics up to limit when input is empty', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: '', + limit: 3, + }); + expect(result).toEqual(sampleMetrics.slice(0, 3)); + }); + + it('should return all metrics when input is whitespace', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: ' ', + limit: 3, + }); + expect(result).toEqual(sampleMetrics.slice(0, 3)); + }); + }); + + describe('simple searches (≤ 4 terms)', () => { + it('should match exact strings', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_requests_total', + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + }); + + it('should match with single character errors', () => { + // substitution + let result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_requezts_total', // 's' replaced with 'z' + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + + // ransposition + result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_reqeust_total', // 'ue' swapped + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + + // deletion + result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_reqests_total', // missing 'u' + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + + // insertion + result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'http_reqquests_total', // extra 'q' + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + }); + + it('should match partial strings', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'requests', // partial match + limit: 10, + }); + expect(result).toContainEqual('http_requests_total'); + expect(result).toContainEqual('http_requests_failed'); + }); + + it('should not match with multiple errors', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'htp_reqests_total', // two errors: missing 't' and missing 'u' + limit: 10, + }); + expect(result).not.toContainEqual('http_requests_total'); + }); + }); + + describe('complex searches (> 4 terms)', () => { + it('should use substring matching for each term', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'metric name 1 with extra terms', + limit: 10, + }); + expect(result).toContainEqual('metric_name_1_with_extra_terms_included'); + }); + + it('should return empty array when no metrics match all terms', () => { + const result = filterMetricNames({ + metricNames: sampleMetrics, + inputText: 'metric name 1 with nonexistent terms', + limit: 10, + }); + expect(result).toHaveLength(0); + }); + + it('should stop searching after limit is reached', () => { + const manyMetrics = Array.from({ length: 10 }, (_, i) => `metric_name_${i}_with_terms`); + + const result = filterMetricNames({ + metricNames: manyMetrics, + inputText: 'metric name with terms other words', // > 4 terms + limit: 3, + }); + + expect(result.length).toBeLessThanOrEqual(3); + }); + }); +}); + type MetricNameSituation = Extract; const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[]; @@ -74,22 +201,22 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); }); - it('should limit completions for metric names when the number of metric names is greater than the limit', async () => { + it('should limit completions for metric names when the number exceeds the limit', async () => { const situation: Situation = { type: situationType, }; const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit); - // No text input - dataProvider.monacoSettings.setInputInRange(''); + // Complex query + dataProvider.monacoSettings.setInputInRange('metric name one two three four five'); let completions = await getCompletions(situation, dataProvider); - expect(completions).toHaveLength(expectedCompletionsCount); + expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); - // With text input (use fuzzy search) - dataProvider.monacoSettings.setInputInRange('name_1'); + // Simple query with fuzzy match + dataProvider.monacoSettings.setInputInRange('metric_name_'); completions = await getCompletions(situation, dataProvider); - expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); + expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); }); it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => { @@ -115,4 +242,35 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat await getCompletions(situation, dataProvider); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); }); + + it('should handle complex queries efficiently', async () => { + const situation: Situation = { + type: situationType, + }; + + const testMetrics = ['metric_name_1', 'metric_name_2', 'metric_name_1_with_extra_terms', 'unrelated_metric']; + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(testMetrics); + + // Test with a complex query (> 4 terms) + dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more'); + const completions = await getCompletions(situation, dataProvider); + + const metricCompletions = completions.filter((c) => c.type === 'METRIC_NAME'); + expect(metricCompletions.some((c) => c.label === 'metric_name_1_with_extra_terms')).toBe(true); + }); + + it('should handle multiple term queries efficiently', async () => { + const situation: Situation = { + type: situationType, + }; + + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit); + + // Test with multiple terms + dataProvider.monacoSettings.setInputInRange('metric name 1 2 3 4 5'); + const completions = await getCompletions(situation, dataProvider); + + const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); + expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); + }); }); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts index d93338c5ea5..95c16738d55 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -22,7 +22,31 @@ type Completion = { triggerOnInsert?: boolean; }; -const metricNamesSearchClient = new UFuzzy({ intraMode: 1 }); +const metricNamesSearch = { + // see https://github.com/leeoniya/uFuzzy?tab=readme-ov-file#how-it-works for details + multiInsert: new UFuzzy({ intraMode: 0 }), + singleError: new UFuzzy({ intraMode: 1 }), +}; + +interface MetricFilterOptions { + metricNames: string[]; + inputText: string; + limit: number; +} + +export function filterMetricNames({ metricNames, inputText, limit }: MetricFilterOptions): string[] { + if (!inputText?.trim()) { + return metricNames.slice(0, limit); + } + + const terms = metricNamesSearch.multiInsert.split(inputText); // e.g. 'some_metric_name or-another' -> ['some', 'metric', 'name', 'or', 'another'] + const isComplexSearch = terms.length > 4; + const fuzzyResults = isComplexSearch + ? metricNamesSearch.multiInsert.filter(metricNames, inputText) // for complex searches, prioritize performance by using MultiInsert fuzzy search + : metricNamesSearch.singleError.filter(metricNames, inputText); // for simple searches, prioritize flexibility by using SingleError fuzzy search + + return fuzzyResults ? fuzzyResults.slice(0, limit).map((idx) => metricNames[idx]) : []; +} // we order items like: history, functions, metrics function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] { @@ -36,11 +60,11 @@ function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] monacoSettings.enableAutocompleteSuggestionsUpdate(); if (monacoSettings.inputInRange) { - metricNames = - metricNamesSearchClient - .filter(metricNames, monacoSettings.inputInRange) - ?.slice(0, dataProvider.metricNamesSuggestionLimit) - .map((idx) => metricNames[idx]) ?? []; + metricNames = filterMetricNames({ + metricNames, + inputText: monacoSettings.inputInRange, + limit: dataProvider.metricNamesSuggestionLimit, + }); } else { metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit); } diff --git a/packages/grafana-prometheus/tsconfig.json b/packages/grafana-prometheus/tsconfig.json index 169e37aca11..bfab9da4959 100644 --- a/packages/grafana-prometheus/tsconfig.json +++ b/packages/grafana-prometheus/tsconfig.json @@ -6,7 +6,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "allowJs": true, - "rootDirs": ["."] + "rootDirs": ["."], + "resolveJsonModule": true }, "exclude": ["dist/**/*"], "extends": "@grafana/tsconfig", diff --git a/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts b/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts index dd689499573..9f8d6e42d9e 100644 --- a/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts +++ b/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts @@ -1,12 +1,15 @@ import { createContext, useContext } from 'react'; import { useObservable } from 'react-use'; +import { locationService as mainLocationService } from './LocationService'; import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL'; export const SidecarContext_EXPERIMENTAL = createContext( sidecarServiceSingleton_EXPERIMENTAL ); +const HIDDEN_ROUTES = ['/login']; + /** * This is the main way to interact with the sidecar service inside a react context. It provides a wrapper around the * service props so that even though they are observables we just pass actual values to the components. @@ -24,17 +27,33 @@ export function useSidecar_EXPERIMENTAL() { const initialContext = useObservable(service.initialContextObservable, service.initialContext); const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId); const locationService = service.getLocationService(); + const forceHidden = HIDDEN_ROUTES.includes(mainLocationService.getLocation().pathname); return { - activePluginId, - initialContext, + activePluginId: forceHidden ? undefined : activePluginId, + initialContext: forceHidden ? undefined : initialContext, locationService, // TODO: currently this allows anybody to open any app, in the future we should probably scope this to the // current app but that means we will need to incorporate this better into the plugin platform APIs which // we will do once the functionality is reasonably stable - openApp: (pluginId: string, context?: unknown) => service.openApp(pluginId, context), - openAppV2: (pluginId: string, path?: string) => service.openAppV2(pluginId, path), + openApp: (pluginId: string, context?: unknown) => { + if (forceHidden) { + return; + } + return service.openApp(pluginId, context); + }, + openAppV2: (pluginId: string, path?: string) => { + if (forceHidden) { + return; + } + return service.openAppV2(pluginId, path); + }, closeApp: () => service.closeApp(), - isAppOpened: (pluginId: string) => service.isAppOpened(pluginId), + isAppOpened: (pluginId: string) => { + if (forceHidden) { + return false; + } + return service.isAppOpened(pluginId); + }, }; } diff --git a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts index c91d2dc9181..98c4a1ff1a0 100644 --- a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts +++ b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts @@ -2,6 +2,7 @@ import * as H from 'history'; import { pick } from 'lodash'; import { BehaviorSubject, map, Observable } from 'rxjs'; +import { reportInteraction } from '../analytics/utils'; import { config } from '../config'; import { HistoryWrapper, LocationService, locationService } from './LocationService'; @@ -92,6 +93,8 @@ export class SidecarService_EXPERIMENTAL { } this._initialContext.next(context); this.memoryLocationService.push({ pathname: `/a/${pluginId}` }); + + reportInteraction('sidecar_service_open_app', { pluginId, version: 1 }); } /** @@ -104,6 +107,7 @@ export class SidecarService_EXPERIMENTAL { } this.memoryLocationService.push({ pathname: `/a/${pluginId}${path || ''}` }); + reportInteraction('sidecar_service_open_app', { pluginId, version: 2 }); } /** @@ -116,6 +120,8 @@ export class SidecarService_EXPERIMENTAL { this._initialContext.next(undefined); this.memoryLocationService.replace({ pathname: '/' }); + + reportInteraction('sidecar_service_close_app'); } /** @@ -139,7 +145,9 @@ export class SidecarService_EXPERIMENTAL { return false; } - return !!(this.activePluginId && (this.activePluginId === pluginId || getMainAppPluginId() === pluginId)); + const result = !!(this.activePluginId && (this.activePluginId === pluginId || getMainAppPluginId() === pluginId)); + reportInteraction('sidecar_service_is_app_opened', { pluginId, isOpened: result }); + return result; } } diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 0eca0ba3b15..8def578309f 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -31,19 +31,16 @@ type UPlotChartState = { export class UPlotChart extends Component { plotContainer = createRef(); plotCanvasBBox = createRef(); + plotInstance: uPlot | null = null; constructor(props: PlotProps) { super(props); - - this.state = { - plot: null, - }; } reinitPlot() { let { width, height, plotRef } = this.props; - this.state.plot?.destroy(); + this.plotInstance?.destroy(); if (width === 0 && height === 0) { return; @@ -69,7 +66,7 @@ export class UPlotChart extends Component { plotRef(plot); } - this.setState({ plot }); + this.plotInstance = plot; } componentDidMount() { @@ -77,21 +74,19 @@ export class UPlotChart extends Component { } componentWillUnmount() { - this.state.plot?.destroy(); + this.plotInstance?.destroy(); } componentDidUpdate(prevProps: PlotProps) { - let { plot } = this.state; - if (!sameDims(prevProps, this.props)) { - plot?.setSize({ + this.plotInstance?.setSize({ width: Math.floor(this.props.width), height: Math.floor(this.props.height), }); } else if (!sameConfig(prevProps, this.props)) { this.reinitPlot(); } else if (!sameData(prevProps, this.props)) { - plot?.setData(this.props.data as AlignedData); + this.plotInstance?.setData(this.props.data as AlignedData); } } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index f78b1fc5f24..bfc1470765d 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -838,14 +838,14 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr if dashboardService == nil { dashboardService, err = service.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, - ac, folderSvc, fStore, nil, + ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), ) require.NoError(t, err) } dashboardProvisioningService, err := service.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, - ac, folderSvc, fStore, nil, + ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), ) require.NoError(t, err) diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 9581cb5795a..a2333c8dbdc 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -478,7 +478,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl( sc.cfg, dashStore, folderStore, features, folderPermissions, dashboardPermissions, ac, - folderServiceWithFlagOn, fStore, nil, + folderServiceWithFlagOn, fStore, nil, zanzana.NewNoopClient(), ) require.NoError(b, err) diff --git a/pkg/expr/sql/db.go b/pkg/expr/sql/db.go index 1e7aca0c1b6..90e23342c4b 100644 --- a/pkg/expr/sql/db.go +++ b/pkg/expr/sql/db.go @@ -9,10 +9,6 @@ import ( type DB struct { } -func (db *DB) TablesList(rawSQL string) ([]string, error) { - return nil, errors.New("not implemented") -} - func (db *DB) RunCommands(commands []string) (string, error) { return "", errors.New("not implemented") } diff --git a/pkg/expr/sql/parser.go b/pkg/expr/sql/parser.go index a5ca1fb6f5b..b5456b3b6ff 100644 --- a/pkg/expr/sql/parser.go +++ b/pkg/expr/sql/parser.go @@ -20,10 +20,10 @@ var logger = log.New("sql_expr") // TablesList returns a list of tables for the sql statement func TablesList(rawSQL string) ([]string, error) { - duckDB := NewInMemoryDB() + db := NewInMemoryDB() rawSQL = strings.Replace(rawSQL, "'", "''", -1) cmd := fmt.Sprintf("SELECT json_serialize_sql('%s')", rawSQL) - ret, err := duckDB.RunCommands([]string{cmd}) + ret, err := db.RunCommands([]string{cmd}) if err != nil { logger.Error("error serializing sql", "error", err.Error(), "sql", rawSQL, "cmd", cmd) return nil, fmt.Errorf("error serializing sql: %s", err.Error()) diff --git a/pkg/services/authz/proto/v1/extention.pb.go b/pkg/services/authz/proto/v1/extention.pb.go index 9e5fdc71d17..53b0a40b152 100644 --- a/pkg/services/authz/proto/v1/extention.pb.go +++ b/pkg/services/authz/proto/v1/extention.pb.go @@ -769,6 +769,242 @@ func (*WriteResponse) Descriptor() ([]byte, []int) { return file_extention_proto_rawDescGZIP(), []int{12} } +type BatchCheckRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Subject string `protobuf:"bytes,1,opt,name=subject,proto3" json:"subject,omitempty"` + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + Items []*BatchCheckItem `protobuf:"bytes,3,rep,name=items,proto3" json:"items,omitempty"` +} + +func (x *BatchCheckRequest) Reset() { + *x = BatchCheckRequest{} + mi := &file_extention_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchCheckRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchCheckRequest) ProtoMessage() {} + +func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchCheckRequest.ProtoReflect.Descriptor instead. +func (*BatchCheckRequest) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{13} +} + +func (x *BatchCheckRequest) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *BatchCheckRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *BatchCheckRequest) GetItems() []*BatchCheckItem { + if x != nil { + return x.Items + } + return nil +} + +type BatchCheckItem struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Verb string `protobuf:"bytes,1,opt,name=verb,proto3" json:"verb,omitempty"` + Group string `protobuf:"bytes,2,opt,name=group,proto3" json:"group,omitempty"` + Resource string `protobuf:"bytes,3,opt,name=resource,proto3" json:"resource,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + Subresource string `protobuf:"bytes,5,opt,name=subresource,proto3" json:"subresource,omitempty"` + Folder string `protobuf:"bytes,6,opt,name=folder,proto3" json:"folder,omitempty"` +} + +func (x *BatchCheckItem) Reset() { + *x = BatchCheckItem{} + mi := &file_extention_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchCheckItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchCheckItem) ProtoMessage() {} + +func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchCheckItem.ProtoReflect.Descriptor instead. +func (*BatchCheckItem) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{14} +} + +func (x *BatchCheckItem) GetVerb() string { + if x != nil { + return x.Verb + } + return "" +} + +func (x *BatchCheckItem) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *BatchCheckItem) GetResource() string { + if x != nil { + return x.Resource + } + return "" +} + +func (x *BatchCheckItem) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *BatchCheckItem) GetSubresource() string { + if x != nil { + return x.Subresource + } + return "" +} + +func (x *BatchCheckItem) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +type BatchCheckResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Groups map[string]*BatchCheckGroupResource `protobuf:"bytes,1,rep,name=groups,proto3" json:"groups,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *BatchCheckResponse) Reset() { + *x = BatchCheckResponse{} + mi := &file_extention_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchCheckResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchCheckResponse) ProtoMessage() {} + +func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchCheckResponse.ProtoReflect.Descriptor instead. +func (*BatchCheckResponse) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{15} +} + +func (x *BatchCheckResponse) GetGroups() map[string]*BatchCheckGroupResource { + if x != nil { + return x.Groups + } + return nil +} + +type BatchCheckGroupResource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Items map[string]bool `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *BatchCheckGroupResource) Reset() { + *x = BatchCheckGroupResource{} + mi := &file_extention_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchCheckGroupResource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchCheckGroupResource) ProtoMessage() {} + +func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchCheckGroupResource.ProtoReflect.Descriptor instead. +func (*BatchCheckGroupResource) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{16} +} + +func (x *BatchCheckGroupResource) GetItems() map[string]bool { + if x != nil { + return x.Items + } + return nil +} + var File_extention_proto protoreflect.FileDescriptor var file_extention_proto_rawDesc = []byte{ @@ -874,27 +1110,74 @@ var file_extention_proto_rawDesc = []byte{ 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x32, 0xfb, 0x01, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x4c, - 0x69, 0x73, 0x74, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, + 0x65, 0x22, 0x85, 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, + 0x38, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, - 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, - 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, + 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, + 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xa4, 0x01, 0x0a, 0x0e, 0x42, 0x61, + 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, + 0x76, 0x65, 0x72, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, + 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, + 0x22, 0xc8, 0x01, 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x73, 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa1, 0x01, 0x0a, 0x17, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, + 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, + 0x69, 0x74, 0x65, 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, + 0xd8, 0x02, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x4c, 0x69, 0x73, + 0x74, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0a, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, - 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, - 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, - 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, - 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, - 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, + 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, + 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -909,7 +1192,7 @@ func file_extention_proto_rawDescGZIP() []byte { return file_extention_proto_rawDescData } -var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_extention_proto_goTypes = []any{ (*ListRequest)(nil), // 0: authz.extention.v1.ListRequest (*ListResponse)(nil), // 1: authz.extention.v1.ListResponse @@ -924,33 +1207,45 @@ var file_extention_proto_goTypes = []any{ (*WriteRequestDeletes)(nil), // 10: authz.extention.v1.WriteRequestDeletes (*WriteRequest)(nil), // 11: authz.extention.v1.WriteRequest (*WriteResponse)(nil), // 12: authz.extention.v1.WriteResponse - (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 14: google.protobuf.Struct - (*wrapperspb.Int32Value)(nil), // 15: google.protobuf.Int32Value + (*BatchCheckRequest)(nil), // 13: authz.extention.v1.BatchCheckRequest + (*BatchCheckItem)(nil), // 14: authz.extention.v1.BatchCheckItem + (*BatchCheckResponse)(nil), // 15: authz.extention.v1.BatchCheckResponse + (*BatchCheckGroupResource)(nil), // 16: authz.extention.v1.BatchCheckGroupResource + nil, // 17: authz.extention.v1.BatchCheckResponse.GroupsEntry + nil, // 18: authz.extention.v1.BatchCheckGroupResource.ItemsEntry + (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 20: google.protobuf.Struct + (*wrapperspb.Int32Value)(nil), // 21: google.protobuf.Int32Value } var file_extention_proto_depIdxs = []int32{ 5, // 0: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition 2, // 1: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey - 13, // 2: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp - 14, // 3: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct + 19, // 2: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp + 20, // 3: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct 7, // 4: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey - 15, // 5: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value + 21, // 5: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value 3, // 6: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple 2, // 7: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey 4, // 8: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition 9, // 9: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites 10, // 10: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes - 0, // 11: authz.extention.v1.AuthzExtentionService.List:input_type -> authz.extention.v1.ListRequest - 6, // 12: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest - 11, // 13: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest - 1, // 14: authz.extention.v1.AuthzExtentionService.List:output_type -> authz.extention.v1.ListResponse - 8, // 15: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse - 12, // 16: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse - 14, // [14:17] is the sub-list for method output_type - 11, // [11:14] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 14, // 11: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem + 17, // 12: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry + 18, // 13: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry + 16, // 14: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource + 0, // 15: authz.extention.v1.AuthzExtentionService.List:input_type -> authz.extention.v1.ListRequest + 13, // 16: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest + 6, // 17: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest + 11, // 18: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest + 1, // 19: authz.extention.v1.AuthzExtentionService.List:output_type -> authz.extention.v1.ListResponse + 15, // 20: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse + 8, // 21: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse + 12, // 22: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse + 19, // [19:23] is the sub-list for method output_type + 15, // [15:19] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_extention_proto_init() } @@ -964,7 +1259,7 @@ func file_extention_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_extention_proto_rawDesc, NumEnums: 0, - NumMessages: 13, + NumMessages: 19, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/authz/proto/v1/extention.proto b/pkg/services/authz/proto/v1/extention.proto index 0c4b67bfd23..45d1607b03c 100644 --- a/pkg/services/authz/proto/v1/extention.proto +++ b/pkg/services/authz/proto/v1/extention.proto @@ -10,6 +10,8 @@ import "google/protobuf/wrappers.proto"; service AuthzExtentionService { rpc List(ListRequest) returns (ListResponse); + rpc BatchCheck(BatchCheckRequest) returns (BatchCheckResponse); + rpc Read(ReadRequest) returns (ReadResponse); rpc Write(WriteRequest) returns (WriteResponse); } @@ -86,3 +88,26 @@ message WriteRequest { } message WriteResponse {} + +message BatchCheckRequest { + string subject = 1; + string namespace = 2; + repeated BatchCheckItem items = 3; +} + +message BatchCheckItem { + string verb = 1; + string group = 2; + string resource = 3; + string name = 4; + string subresource = 5; + string folder = 6; +} + +message BatchCheckResponse { + map groups = 1; +} + +message BatchCheckGroupResource { + map items = 1; +} diff --git a/pkg/services/authz/proto/v1/extention_grpc.pb.go b/pkg/services/authz/proto/v1/extention_grpc.pb.go index 7b73cf22306..ee814e97a0f 100644 --- a/pkg/services/authz/proto/v1/extention_grpc.pb.go +++ b/pkg/services/authz/proto/v1/extention_grpc.pb.go @@ -19,9 +19,10 @@ import ( const _ = grpc.SupportPackageIsVersion8 const ( - AuthzExtentionService_List_FullMethodName = "/authz.extention.v1.AuthzExtentionService/List" - AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read" - AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write" + AuthzExtentionService_List_FullMethodName = "/authz.extention.v1.AuthzExtentionService/List" + AuthzExtentionService_BatchCheck_FullMethodName = "/authz.extention.v1.AuthzExtentionService/BatchCheck" + AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read" + AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write" ) // AuthzExtentionServiceClient is the client API for AuthzExtentionService service. @@ -29,6 +30,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type AuthzExtentionServiceClient interface { List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) + BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) Write(ctx context.Context, in *WriteRequest, opts ...grpc.CallOption) (*WriteResponse, error) } @@ -51,6 +53,16 @@ func (c *authzExtentionServiceClient) List(ctx context.Context, in *ListRequest, return out, nil } +func (c *authzExtentionServiceClient) BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BatchCheckResponse) + err := c.cc.Invoke(ctx, AuthzExtentionService_BatchCheck_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authzExtentionServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ReadResponse) @@ -76,6 +88,7 @@ func (c *authzExtentionServiceClient) Write(ctx context.Context, in *WriteReques // for forward compatibility type AuthzExtentionServiceServer interface { List(context.Context, *ListRequest) (*ListResponse, error) + BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error) Read(context.Context, *ReadRequest) (*ReadResponse, error) Write(context.Context, *WriteRequest) (*WriteResponse, error) } @@ -87,6 +100,9 @@ type UnimplementedAuthzExtentionServiceServer struct { func (UnimplementedAuthzExtentionServiceServer) List(context.Context, *ListRequest) (*ListResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method List not implemented") } +func (UnimplementedAuthzExtentionServiceServer) BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method BatchCheck not implemented") +} func (UnimplementedAuthzExtentionServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Read not implemented") } @@ -123,6 +139,24 @@ func _AuthzExtentionService_List_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _AuthzExtentionService_BatchCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BatchCheckRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthzExtentionServiceServer).BatchCheck(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthzExtentionService_BatchCheck_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthzExtentionServiceServer).BatchCheck(ctx, req.(*BatchCheckRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthzExtentionService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ReadRequest) if err := dec(in); err != nil { @@ -170,6 +204,10 @@ var AuthzExtentionService_ServiceDesc = grpc.ServiceDesc{ MethodName: "List", Handler: _AuthzExtentionService_List_Handler, }, + { + MethodName: "BatchCheck", + Handler: _AuthzExtentionService_BatchCheck_Handler, + }, { MethodName: "Read", Handler: _AuthzExtentionService_Read_Handler, diff --git a/pkg/services/authz/zanzana/client.go b/pkg/services/authz/zanzana/client.go index 9bb9fca456e..e7b6a7a2715 100644 --- a/pkg/services/authz/zanzana/client.go +++ b/pkg/services/authz/zanzana/client.go @@ -16,6 +16,7 @@ type Client interface { List(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (*authzextv1.ListResponse, error) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) Write(ctx context.Context, req *authzextv1.WriteRequest) error + BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) } func NewNoopClient() *client.NoopClient { diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 819cf2b91a5..784a95e5a77 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -169,3 +169,10 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error _, err := c.authzext.Write(ctx, req) return err } + +func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + ctx, span := tracer.Start(ctx, "authz.zanzana.client.Check") + defer span.End() + + return c.authzext.BatchCheck(ctx, req) +} diff --git a/pkg/services/authz/zanzana/client/noop.go b/pkg/services/authz/zanzana/client/noop.go index 40d12ac0496..3ea571c6bd2 100644 --- a/pkg/services/authz/zanzana/client/noop.go +++ b/pkg/services/authz/zanzana/client/noop.go @@ -36,3 +36,7 @@ func (nc NoopClient) Read(ctx context.Context, req *authzextv1.ReadRequest) (*au func (nc NoopClient) Write(ctx context.Context, req *authzextv1.WriteRequest) error { return nil } + +func (nc NoopClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + return nil, nil +} diff --git a/pkg/services/authz/zanzana/server/server_batch_check.go b/pkg/services/authz/zanzana/server/server_batch_check.go new file mode 100644 index 00000000000..22b6b3ecec1 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_batch_check.go @@ -0,0 +1,64 @@ +package server + +import ( + "context" + + authzv1 "github.com/grafana/authlib/authz/proto/v1" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + ctx, span := tracer.Start(ctx, "authzServer.BatchCheck") + defer span.End() + + batchRes := &authzextv1.BatchCheckResponse{ + Groups: make(map[string]*authzextv1.BatchCheckGroupResource), + } + + subject := r.GetSubject() + + for _, item := range r.Items { + groupPrefix := common.FormatGroupResource(item.GetGroup(), item.GetResource()) + allowed, err := s.batchCheckItem(ctx, subject, r.Namespace, item) + if err != nil { + return nil, err + } + + if _, ok := batchRes.Groups[groupPrefix]; !ok { + batchRes.Groups[groupPrefix] = &authzextv1.BatchCheckGroupResource{ + Items: make(map[string]bool), + } + } + batchRes.Groups[groupPrefix].Items[item.GetName()] = allowed + } + + return batchRes, nil +} + +func (s *Server) batchCheckItem(ctx context.Context, subject string, namespace string, item *authzextv1.BatchCheckItem) (bool, error) { + req := &authzv1.CheckRequest{ + Namespace: namespace, + Subject: subject, + Verb: item.GetVerb(), + Group: item.GetGroup(), + Resource: item.GetResource(), + Name: item.GetName(), + Folder: item.GetFolder(), + Subresource: item.GetSubresource(), + } + + var res *authzv1.CheckResponse + var err error + if info, ok := common.GetTypeInfo(item.GetGroup(), item.GetResource()); ok { + res, err = s.checkTyped(ctx, req, info) + } else { + res, err = s.checkGeneric(ctx, req) + } + if err != nil { + return false, err + } + + return res.Allowed, nil +} diff --git a/pkg/services/authz/zanzana/server/server_batch_check_test.go b/pkg/services/authz/zanzana/server/server_batch_check_test.go new file mode 100644 index 00000000000..9b64be2651f --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_batch_check_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" +) + +func newBatch(subject, group, resource string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest { + for i, item := range items { + items[i] = &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: group, + Resource: resource, + Name: item.GetName(), + Folder: item.GetFolder(), + } + } + + return &authzextv1.BatchCheckRequest{ + Namespace: "default", + Subject: subject, + Items: items, + } +} + +func testBatchCheck(t *testing.T, server *Server) { + t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ + {Name: "1", Folder: "1"}, + {Name: "2", Folder: "2"}, + })) + require.NoError(t, err) + require.Len(t, res.Groups[groupPrefix].Items, 2) + + assert.True(t, res.Groups[groupPrefix].Items["1"]) + assert.False(t, res.Groups[groupPrefix].Items["2"]) + }) + + t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ + {Name: "1", Folder: "1"}, + {Name: "2", Folder: "2"}, + })) + require.NoError(t, err) + assert.Len(t, res.Groups[groupPrefix].Items, 2) + }) + + t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ + {Name: "1", Folder: "1"}, + {Name: "2", Folder: "2"}, + })) + require.NoError(t, err) + require.Len(t, res.Groups[groupPrefix].Items, 2) + + assert.True(t, res.Groups[groupPrefix].Items["1"]) + assert.False(t, res.Groups[groupPrefix].Items["2"]) + }) + + t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ + {Name: "1", Folder: "1"}, + {Name: "2", Folder: "3"}, + {Name: "3", Folder: "2"}, + })) + require.NoError(t, err) + require.Len(t, res.Groups[groupPrefix].Items, 3) + + assert.True(t, res.Groups[groupPrefix].Items["1"]) + assert.True(t, res.Groups[groupPrefix].Items["2"]) + assert.False(t, res.Groups[groupPrefix].Items["3"]) + }) + + t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ + {Name: "1", Folder: "1"}, + {Name: "2", Folder: "2"}, + })) + require.NoError(t, err) + require.Len(t, res.Groups[groupPrefix].Items, 2) + + assert.True(t, res.Groups[groupPrefix].Items["1"]) + assert.False(t, res.Groups[groupPrefix].Items["2"]) + }) + + t.Run("user:6 should be able to read folder 1", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(folderGroup, folderResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:6", folderGroup, folderResource, []*authzextv1.BatchCheckItem{ + {Name: "1"}, + {Name: "2"}, + })) + require.NoError(t, err) + require.Len(t, res.Groups[groupPrefix].Items, 2) + + assert.True(t, res.Groups[groupPrefix].Items["1"]) + assert.False(t, res.Groups[groupPrefix].Items["2"]) + }) + + t.Run("user:7 should be able to read folder {1,2} through namespace access", func(t *testing.T) { + groupPrefix := zanzana.FormatGroupResource(folderGroup, folderResource) + res, err := server.BatchCheck(context.Background(), newBatch("user:7", folderGroup, folderResource, []*authzextv1.BatchCheckItem{ + {Name: "1"}, + {Name: "2"}, + })) + require.NoError(t, err) + require.Len(t, res.Groups[groupPrefix].Items, 2) + }) +} diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index 3bb77aaf182..f0e78045292 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -20,6 +20,31 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C return s.checkGeneric(ctx, r) } +// checkNamespace checks if subject has access through namespace +func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { + storeInf, err := s.getStoreInfo(ctx, r.Namespace) + if err != nil { + return nil, err + } + + relation := common.VerbMapping[r.GetVerb()] + + res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ + StoreId: storeInf.Id, + AuthorizationModelId: storeInf.AuthorizationModelId, + TupleKey: &openfgav1.CheckRequestTupleKey{ + User: r.GetSubject(), + Relation: relation, + Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), + }, + }) + if err != nil { + return nil, err + } + + return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil +} + func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info common.TypeInfo) (*authzv1.CheckResponse, error) { storeInf, err := s.getStoreInfo(ctx, r.Namespace) if err != nil { @@ -47,20 +72,12 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info c } // 2. check if subject has access through namespace - res, err = s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), - Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), - }, - }) + nsRes, err := s.checkNamespace(ctx, r) if err != nil { return nil, err } - return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil + return &authzv1.CheckResponse{Allowed: nsRes.GetAllowed()}, nil } func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { @@ -96,21 +113,12 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au } // 2. check if subject has access through namespace - res, err = s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), - Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), - }, - }) - + nsRes, err := s.checkNamespace(ctx, r) if err != nil { return nil, err } - if res.GetAllowed() { + if nsRes.GetAllowed() { return &authzv1.CheckResponse{Allowed: true}, nil } diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 9e04ceb117b..afa49bb6757 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -49,6 +49,10 @@ func TestIntegrationServer(t *testing.T) { t.Run("test list", func(t *testing.T) { testList(t, srv) }) + + t.Run("test batch check", func(t *testing.T) { + testBatchCheck(t, srv) + }) } func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { diff --git a/pkg/services/authz/zanzana/zanzana.go b/pkg/services/authz/zanzana/zanzana.go index 2001d248780..52a63697cb4 100644 --- a/pkg/services/authz/zanzana/zanzana.go +++ b/pkg/services/authz/zanzana/zanzana.go @@ -83,6 +83,8 @@ var ( ToOpenFGATuples = common.ToOpenFGATuples ToOpenFGATupleKey = common.ToOpenFGATupleKey ToOpenFGATupleKeyWithoutCondition = common.ToOpenFGATupleKeyWithoutCondition + + FormatGroupResource = common.FormatGroupResource ) // NewTupleEntry constructs new openfga entry type:name[#relation]. @@ -166,6 +168,14 @@ func TranslateToCheckRequest(namespace, action, kind, folder, name string) (*aut return req, true } +func TranslateToGroupResource(kind string) string { + translation, ok := resourceTranslations[kind] + if !ok { + return "" + } + return common.FormatGroupResource(translation.group, translation.resource) +} + func TranslateBasicRole(name string) string { return basicRolesTranslations[name] } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 168400e5b95..6f8def67f13 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/datasources" @@ -59,6 +60,7 @@ type DashboardServiceImpl struct { folderPermissions accesscontrol.FolderPermissionsService dashboardPermissions accesscontrol.DashboardPermissionsService ac accesscontrol.AccessControl + zclient zanzana.Client metrics *dashboardsMetrics } @@ -67,7 +69,7 @@ func ProvideDashboardServiceImpl( cfg *setting.Cfg, dashboardStore dashboards.Store, folderStore folder.FolderStore, features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, ac accesscontrol.AccessControl, - folderSvc folder.Service, fStore folder.Store, r prometheus.Registerer, + folderSvc folder.Service, fStore folder.Store, r prometheus.Registerer, zclient zanzana.Client, ) (*DashboardServiceImpl, error) { dashSvc := &DashboardServiceImpl{ cfg: cfg, @@ -77,6 +79,7 @@ func ProvideDashboardServiceImpl( folderPermissions: folderPermissionsService, dashboardPermissions: dashboardPermissionsService, ac: ac, + zclient: zclient, folderStore: folderStore, folderService: folderSvc, metrics: newDashboardsMetrics(r), diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 8333cdf0d4d..1ab159bc0b5 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -883,6 +884,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc foldertest.NewFakeService(), folder.NewFakeStore(), nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) guardian.InitAccessControlGuardian(cfg, ac, dashboardService) @@ -949,6 +951,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt foldertest.NewFakeService(), folder.NewFakeStore(), nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) res, err := service.SaveDashboard(context.Background(), &dto, false) @@ -974,6 +977,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto foldertest.NewFakeService(), folder.NewFakeStore(), nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) _, err = service.SaveDashboard(context.Background(), &dto, false) @@ -1018,6 +1022,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string foldertest.NewFakeService(), folder.NewFakeStore(), nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) res, err := service.SaveDashboard(context.Background(), &dto, false) @@ -1069,6 +1074,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da foldertest.NewFakeService(), folder.NewFakeStore(), nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) res, err := service.SaveDashboard(context.Background(), &dto, false) diff --git a/pkg/services/dashboards/service/zanzana.go b/pkg/services/dashboards/service/zanzana.go index 5d72c776742..468032c6213 100644 --- a/pkg/services/dashboards/service/zanzana.go +++ b/pkg/services/dashboards/service/zanzana.go @@ -6,9 +6,15 @@ import ( "github.com/prometheus/client_golang/prometheus" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" ) +const ( + defaultQueryLimit = 1000 +) + type searchResult struct { runner string result []dashboards.DashboardSearchProjection @@ -79,7 +85,113 @@ func (dr *DashboardServiceImpl) findDashboardsZanzanaCompare(ctx context.Context return first.result, first.err } -func (dr *DashboardServiceImpl) findDashboardsZanzana(_ context.Context, _ dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { - // FIXME: Implement using the new schema - return []dashboards.DashboardSearchProjection{}, nil +func (dr *DashboardServiceImpl) findDashboardsZanzana(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + return dr.findDashboardsZanzanaCheck(ctx, query) +} + +// findDashboardsZanzanaCheck implements "Search, then check" strategy. It first performs search query, then filters out results +// by checking access to each item. +func (dr *DashboardServiceImpl) findDashboardsZanzanaCheck(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaCheck") + defer span.End() + + result := make([]dashboards.DashboardSearchProjection, 0, query.Limit) + + query.SkipAccessControlFilter = true + // Remember initial query limit + limit := query.Limit + // Set limit to default to prevent pagination issues + query.Limit = defaultQueryLimit + if query.Page == 0 { + query.Page = 1 + } + + for len(result) < int(limit) { + findRes, err := dr.dashboardStore.FindDashboards(ctx, &query) + if err != nil { + return nil, err + } + + remains := limit - int64(len(result)) + res, err := dr.checkDashboardsBatch(ctx, query, findRes, remains) + if err != nil { + return nil, err + } + + result = append(result, res...) + query.Page++ + + // Stop when last page reached + if len(findRes) < defaultQueryLimit { + break + } + } + + return result, nil +} + +func (dr *DashboardServiceImpl) checkDashboardsBatch(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, searchRes []dashboards.DashboardSearchProjection, remains int64) ([]dashboards.DashboardSearchProjection, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.checkDashboardsBatch") + defer span.End() + + if len(searchRes) == 0 { + return nil, nil + } + + batchReqItems := make([]*authzextv1.BatchCheckItem, 0, len(searchRes)) + + for _, d := range searchRes { + // FIXME: support different access levels + kind := zanzana.KindDashboards + action := dashboards.ActionDashboardsRead + if d.IsFolder { + kind = zanzana.KindFolders + action = dashboards.ActionFoldersRead + } + + checkReq, ok := zanzana.TranslateToCheckRequest("", action, kind, d.FolderUID, d.UID) + if !ok { + continue + } + + batchReqItems = append(batchReqItems, &authzextv1.BatchCheckItem{ + Verb: checkReq.Verb, + Group: checkReq.Group, + Resource: checkReq.Resource, + Name: checkReq.Name, + Folder: checkReq.Folder, + Subresource: checkReq.Subresource, + }) + } + + batchReq := authzextv1.BatchCheckRequest{ + Namespace: query.SignedInUser.GetNamespace(), + Subject: query.SignedInUser.GetUID(), + Items: batchReqItems, + } + + res, err := dr.zclient.BatchCheck(ctx, &batchReq) + if err != nil { + return nil, err + } + + result := make([]dashboards.DashboardSearchProjection, 0) + for _, d := range searchRes { + if len(result) >= int(remains) { + break + } + + kind := zanzana.KindDashboards + if d.IsFolder { + kind = zanzana.KindFolders + } + groupResource := zanzana.TranslateToGroupResource(kind) + if group, ok := res.Groups[groupResource]; ok { + if allowed := group.Items[d.UID]; allowed { + result = append(result, d) + } + } + } + + return result, nil } diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index c8fad1ad7d6..13ce9928ead 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -12,6 +12,7 @@ import ( dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" "github.com/grafana/grafana/pkg/infra/db" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" dashdb "github.com/grafana/grafana/pkg/services/dashboards/database" dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -99,7 +100,7 @@ func TestValidateDashboardExists(t *testing.T) { secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) dashboardStore, err := dashdb.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - dashSvc, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashboardStore, folderimpl.ProvideDashboardFolderStore(sqlStore), nil, nil, nil, acmock.New(), foldertest.NewFakeService(), folder.NewFakeStore(), nil) + dashSvc, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashboardStore, folderimpl.ProvideDashboardFolderStore(sqlStore), nil, nil, nil, acmock.New(), foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient()) require.NoError(t, err) s := ProvideService(dsStore, secretsService, dashSvc) ctx := context.Background() diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 017863a36c5..f75f426c3a3 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -490,7 +490,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { CanEditValue: true, }) - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil) + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, zanzana.NewNoopClient()) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac, b) @@ -573,7 +573,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { }) dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOff, - folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil) + folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, zanzana.NewNoopClient()) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b) @@ -719,7 +719,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { tc.service.dashboardStore = dashStore tc.service.store = nestedFolderStore - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, tc.service.store, nil) + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, tc.service.store, nil, zanzana.NewNoopClient()) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, dashSrv, ac, b) require.NoError(t, err) @@ -1504,6 +1504,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { serviceWithFlagOn, nestedFolderStore, nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 94e19f86380..4de7051404c 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -310,6 +310,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash foldertest.NewFakeService(), folder.NewFakeStore(), nil, + zanzana.NewNoopClient(), ) require.NoError(t, err) dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) @@ -396,7 +397,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, + nil, zanzana.NewNoopClient(), ) require.NoError(t, svcErr) guardian.InitAccessControlGuardian(cfg, ac, dashboardService) @@ -458,7 +459,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, + nil, zanzana.NewNoopClient(), ) require.NoError(t, dashSvcErr) guardian.InitAccessControlGuardian(cfg, ac, dashService) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index f61304f9837..99bd097c883 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -7,6 +7,9 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" @@ -19,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol/testutil" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -38,8 +42,6 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testsuite" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) const userInDbName = "user_in_db" @@ -734,7 +736,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, + nil, zanzana.NewNoopClient(), ) require.NoError(t, err) dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) @@ -831,7 +833,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo cfg, dashStore, folderStore, features, acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, + nil, zanzana.NewNoopClient(), ) require.NoError(t, err) guardian.InitAccessControlGuardian(cfg, ac, dashService) diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go index 417680c39c2..d74d6e5c2ef 100644 --- a/pkg/services/ngalert/testutil/testutil.go +++ b/pkg/services/ngalert/testutil/testutil.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -61,7 +62,7 @@ func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.Dashboa cfg, dashboardStore, fs, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, + nil, zanzana.NewNoopClient(), ) require.NoError(tb, err) diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index e12350ddba4..e94df756efa 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -10,12 +10,13 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/components/simplejson" @@ -24,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -324,7 +326,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) dashService, err := service.ProvideDashboardServiceImpl( cfg, dashboardStoreService, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, - foldertest.NewFakeService(), folder.NewFakeStore(), nil, + foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), ) require.NoError(t, err) diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index c11ab34dd13..3e2a18787c9 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -184,13 +184,13 @@ func addAnnotationMig(mg *Migrator) { Postgres("ALTER TABLE annotation ALTER COLUMN tags TYPE VARCHAR(4096);"). Mysql("ALTER TABLE annotation MODIFY tags VARCHAR(4096);")) - mg.AddMigration("Increase prev_state column to length 40", NewRawSQLMigration(""). - Postgres("ALTER TABLE annotation ALTER COLUMN prev_state TYPE VARCHAR(40);"). - Mysql("ALTER TABLE annotation MODIFY prev_state VARCHAR(40);")) + mg.AddMigration("Increase prev_state column to length 40 not null", NewRawSQLMigration(""). + Postgres("ALTER TABLE annotation ALTER COLUMN prev_state TYPE VARCHAR(40);"). // Does not modify nullability. + Mysql("ALTER TABLE annotation MODIFY prev_state VARCHAR(40) NOT NULL;")) - mg.AddMigration("Increase new_state column to length 40", NewRawSQLMigration(""). - Postgres("ALTER TABLE annotation ALTER COLUMN new_state TYPE VARCHAR(40);"). - Mysql("ALTER TABLE annotation MODIFY new_state VARCHAR(40);")) + mg.AddMigration("Increase new_state column to length 40 not null", NewRawSQLMigration(""). + Postgres("ALTER TABLE annotation ALTER COLUMN new_state TYPE VARCHAR(40);"). // Does not modify nullability. + Mysql("ALTER TABLE annotation MODIFY new_state VARCHAR(40) NOT NULL;")) } type AddMakeRegionSingleRowMigration struct { diff --git a/pkg/tsdb/elasticsearch/data_query.go b/pkg/tsdb/elasticsearch/data_query.go index 405d3b87cb6..4fb4eaaa9f1 100644 --- a/pkg/tsdb/elasticsearch/data_query.go +++ b/pkg/tsdb/elasticsearch/data_query.go @@ -77,10 +77,17 @@ func (e *elasticsearchDataQuery) execute() (*backend.QueryDataResponse, error) { e.logger.Info("Prepared request", "queriesLength", len(queries), "duration", time.Since(start), "stage", es.StagePrepareRequest) res, err := e.client.ExecuteMultisearch(req) if err != nil { - // We are returning error containing the source that was added through errorsource.Middleware + if backend.IsDownstreamHTTPError(err) { + err = errorsource.DownstreamError(err, false) + } return errorsource.AddErrorToResponse(e.dataQueries[0].RefID, response, err), nil } + if res.Status >= 400 { + errWithSource := errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.Status), fmt.Errorf("unexpected status code: %d", res.Status), false) + return errorsource.AddErrorToResponse(e.dataQueries[0].RefID, response, errWithSource), nil + } + return parseResponse(e.ctx, res.Responses, queries, e.client.GetConfiguredFields(), e.keepLabelsInResponse, e.logger) } diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index 2b7a43725ba..f48d1d64889 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" exp "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" - exphttpclient "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource/httpclient" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" ) @@ -90,10 +89,7 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins httpCliOpts.SigV4.Service = "es" } - // set the default middlewars from the httpClientProvider - httpCliOpts.Middlewares = httpClientProvider.Opts.Middlewares - // enable experimental http client to support errors with source - httpCli, err := exphttpclient.New(httpCliOpts) + httpCli, err := httpClientProvider.New(httpCliOpts) if err != nil { return nil, err } diff --git a/pkg/tsdb/elasticsearch/error_handling_test.go b/pkg/tsdb/elasticsearch/error_handling_test.go index 30cc6c0c43c..c6407a7e35f 100644 --- a/pkg/tsdb/elasticsearch/error_handling_test.go +++ b/pkg/tsdb/elasticsearch/error_handling_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "github.com/stretchr/testify/require" ) @@ -43,7 +44,8 @@ func TestErrorAvgMissingField(t *testing.T) { require.NoError(t, err) // FIXME: we should return the received error message - require.Len(t, result.response.Responses, 0) + require.Equal(t, "unexpected status code: 400", result.response.Responses["A"].Error.Error()) + require.Equal(t, backend.ErrorSourceDownstream, result.response.Responses["A"].ErrorSource) } func TestErrorAvgMissingFieldNoDetailedErrors(t *testing.T) { @@ -71,7 +73,7 @@ func TestErrorAvgMissingFieldNoDetailedErrors(t *testing.T) { require.NoError(t, err) // FIXME: we should return the received error message - require.Len(t, result.response.Responses, 0) + require.Equal(t, "unexpected status code: 400", result.response.Responses["A"].Error.Error()) } func TestErrorTooManyDateHistogramBuckets(t *testing.T) { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 7c8d9399940..d70663f1491 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -44,7 +44,15 @@ export interface LoadDashboardOptions { uid: string; route: DashboardRoutes; urlFolderUid?: string; - queryParams?: UrlQueryMap; + params?: { + version: number; + scopes: string[]; + timeRange: { + from: string; + to: string; + }; + variables: UrlQueryMap; + }; } export class DashboardScenePageStateManager extends StateManagerBase { @@ -59,11 +67,11 @@ export class DashboardScenePageStateManager extends StateManagerBase { const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid; - if (!queryParams) { + if (!params) { const cachedDashboard = this.getDashboardFromCache(cacheKey); if (cachedDashboard) { @@ -97,6 +105,15 @@ export class DashboardScenePageStateManager extends StateManagerBase { - private _scopesFacade: ScopesFacade | null = null; - constructor(state: DashboardReloadBehaviorState) { const shouldReload = state.reloadOnParamsChange && state.uid; super(state); - this.reloadDashboard = this.reloadDashboard.bind(this); + // Sometimes the reload is triggered multiple subsequent times + // Debouncing it prevents double/triple reloads + this.reloadDashboard = debounce(this.reloadDashboard).bind(this); if (shouldReload) { this.addActivationHandler(() => { - this._scopesFacade = getClosestScopesFacade(this); + getClosestScopesFacade(this)?.setState({ + handler: this.reloadDashboard, + }); this._variableDependency = new VariableDependencyConfig(this, { onAnyVariableChanged: this.reloadDashboard, }); - this._scopesFacade?.setState({ - handler: this.reloadDashboard, - }); - this._subs.add( sceneGraph.getTimeRange(this).subscribeToState((newState, prevState) => { if (!isEqual(newState.value, prevState.value)) { @@ -41,6 +39,8 @@ export class DashboardReloadBehavior extends SceneObjectBase scope.metadata.name), - ...timeRange.urlSync?.getUrlState(), - }; - - params = sceneGraph.getVariables(this).state.variables.reduce( - (acc, variable) => ({ - ...acc, - ...variable.urlSync?.getUrlState(), - }), - params - ); - - getDashboardScenePageStateManager().reloadDashboard(params); + // This is wrapped in setTimeout in order to allow variables and scopes to be set in the URL before actually reloading the dashboard + setTimeout(() => { + getDashboardScenePageStateManager().reloadDashboard({ + version: this.state.version!, + scopes: getClosestScopesFacade(this)?.value.map((scope) => scope.metadata.name) ?? [], + // We're not using the getUrlState from timeRange since it makes more sense to pass the absolute timestamps as opposed to relative time + timeRange: { + from: timeRange.state.value.from.toISOString(), + to: timeRange.state.value.to.toISOString(), + }, + variables: sceneGraph.getVariables(this).state.variables.reduce( + (acc, variable) => ({ + ...acc, + ...variable.urlSync?.getUrlState(), + }), + {} + ), + }); + }); } } } diff --git a/public/app/features/scopes/tests/dashboardReload.test.ts b/public/app/features/scopes/tests/dashboardReload.test.ts index 84856e82aec..24b07bca51a 100644 --- a/public/app/features/scopes/tests/dashboardReload.test.ts +++ b/public/app/features/scopes/tests/dashboardReload.test.ts @@ -1,7 +1,8 @@ import { config } from '@grafana/runtime'; import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; -import { enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions'; +import { clearMocks, enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions'; import { expectDashboardReload, expectNotDashboardReload } from './utils/assertions'; import { getDatasource, getInstanceSettings, getMock } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; @@ -15,84 +16,66 @@ jest.mock('@grafana/runtime', () => ({ usePluginLinks: jest.fn().mockReturnValue({ links: [] }), })); -const runTest = async ( - reloadDashboardsOnParamsChange: boolean, - reloadOnParamsChange: boolean, - withUid: boolean, - editMode: boolean -) => { - config.featureToggles.reloadDashboardsOnParamsChange = reloadDashboardsOnParamsChange; - setDashboardAPI(undefined); - const uid = 'dash-1'; - const dashboardScene = renderDashboard({ uid: withUid ? uid : undefined }, { reloadOnParamsChange }); - - if (editMode) { - await enterEditMode(dashboardScene); - } - - const shouldReload = reloadDashboardsOnParamsChange && reloadOnParamsChange && withUid && !editMode; - - await updateTimeRange(dashboardScene); - if (!shouldReload) { - expectNotDashboardReload(); - } else { - expectDashboardReload(); - } - - await updateMyVar(dashboardScene, '2'); - if (!shouldReload) { - expectNotDashboardReload(); - } else { - expectDashboardReload(); - } - - await updateScopes(['grafana']); - if (!shouldReload) { - expectNotDashboardReload(); - } else { - expectDashboardReload(); - } -}; - describe('Dashboard reload', () => { beforeAll(() => { config.featureToggles.scopeFilters = true; config.featureToggles.groupByVariable = true; }); - afterEach(async () => { - setDashboardAPI(undefined); - await resetScenes(); - }); + it.each([ + [false, false, false, false], + [false, false, true, false], + [false, true, false, false], + [false, true, true, false], + [true, false, false, false], + [true, false, true, false], + [true, true, false, true], + [true, true, true, true], + [true, true, false, false], + [true, true, true, false], + ])( + `reloadDashboardsOnParamsChange: %s, reloadOnParamsChange: %s, withUid: %s, editMode: %s`, + async (reloadDashboardsOnParamsChange, reloadOnParamsChange, withUid, editMode) => { + config.featureToggles.reloadDashboardsOnParamsChange = reloadDashboardsOnParamsChange; + setDashboardAPI(undefined); - describe('reloadDashboardsOnParamsChange off', () => { - describe('reloadOnParamsChange off', () => { - it('with UID - no reload', () => runTest(false, false, true, false)); - it('without UID - no reload', () => runTest(false, false, false, false)); - }); + const dashboardScene = renderDashboard({ uid: withUid ? 'dash-1' : undefined }, { reloadOnParamsChange }); - describe('reloadOnParamsChange on', () => { - it('with UID - no reload', () => runTest(false, true, true, false)); - it('without UID - no reload', () => runTest(false, true, false, false)); - }); - }); + if (editMode) { + await enterEditMode(dashboardScene); + } - describe('reloadDashboardsOnParamsChange on', () => { - describe('reloadOnParamsChange off', () => { - it('with UID - no reload', () => runTest(true, false, true, false)); - it('without UID - no reload', () => runTest(true, false, false, false)); - }); + const shouldReload = reloadDashboardsOnParamsChange && reloadOnParamsChange && withUid && !editMode; - describe('reloadOnParamsChange on', () => { - describe('edit mode on', () => { - it('with UID - no reload', () => runTest(true, true, true, true)); - it('without UID - no reload', () => runTest(true, true, false, true)); - }); + await updateTimeRange(dashboardScene); + await jest.advanceTimersToNextTimerAsync(); + if (!shouldReload) { + expectNotDashboardReload(); + } else { + expectDashboardReload(); + } - describe('edit mode off', () => { - it('with UID - reload', () => runTest(true, true, true, false)); - it('without UID - no reload', () => runTest(true, true, false, false)); - }); - }); - }); + await updateMyVar(dashboardScene, '2'); + await jest.advanceTimersToNextTimerAsync(); + if (!shouldReload) { + expectNotDashboardReload(); + } else { + expectDashboardReload(); + } + + await updateScopes(['grafana']); + await jest.advanceTimersToNextTimerAsync(); + if (!shouldReload) { + expectNotDashboardReload(); + } else { + expectDashboardReload(); + } + + getDashboardScenePageStateManager().clearDashboardCache(); + getDashboardScenePageStateManager().clearSceneCache(); + setDashboardAPI(undefined); + await resetScenes(); + clearMocks(); + } + ); }); diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index fe9161312b2..1f868a8ab53 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -164,11 +164,9 @@ export class MetricActionBar extends SceneObjectBase { public openExploreLink = async () => { reportExploreMetrics('selected_metric_action_clicked', { action: 'open_in_explore' }); this.getLinkToExplore().then((link) => { - // We need to ensure we prefix with the appSubUrl for environments that don't host grafana at the root. - const url = `${config.appSubUrl}${link}`; // We use window.open instead of a Link or because we want to compute the explore link when clicking, // if we precompute it we have to keep track of a lot of dependencies - window.open(url, '_blank'); + window.open(link, '_blank'); }); }; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx index 94e9afd71e4..8d9c874bb38 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx @@ -22,7 +22,13 @@ export const Aggregation = (props: Props) => { const selected = useSelectedFromOptions(aggOptions, props); return ( - +