Skip to content

Commit 94ed2ee

Browse files
committed
feat(canvas): gracefully filter excluded fields based on metrics security policy
When a metrics view security policy excludes a measure or dimension, canvas components now silently drop the excluded fields instead of showing a ComponentError or stuck loading state. - KPI Grid: filters out excluded measures before rendering; shows only the KPIs the current user can access - Table (flat): filters inaccessible columns; only errors if no columns remain - Pivot: filters inaccessible measures/dimensions; only errors if nothing usable remains - Leaderboard: filters inaccessible measures/dimensions consistently - Pivot display now passes loading state to validator to avoid a brief "Metrics view not found" flash on initial load Closes #8850
1 parent c900751 commit 94ed2ee

5 files changed

Lines changed: 118 additions & 107 deletions

File tree

web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,35 @@
1313
$: ({
1414
specStore,
1515
timeAndFilterStore,
16-
parent: { name: canvasName },
16+
parent: { name: canvasName, metricsView },
1717
visible,
1818
} = component);
1919
$: kpiGridProperties = $specStore;
2020
$: schema = validateKPIGridSchema(kpiGridProperties);
2121
22-
// Convert measures to KPI specs
23-
$: kpis = (kpiGridProperties.measures || []).map((measure) => ({
24-
metrics_view: kpiGridProperties.metrics_view,
25-
measure,
26-
sparkline: kpiGridProperties.sparkline,
27-
hide_time_range: kpiGridProperties.hide_time_range,
28-
comparison: kpiGridProperties.comparison,
29-
dimension_filters: kpiGridProperties.dimension_filters,
30-
time_filters: kpiGridProperties.time_filters,
31-
}));
22+
$: metricsViewQuery = metricsView.getMetricsViewFromName(
23+
kpiGridProperties.metrics_view,
24+
);
25+
$: accessibleMeasureNames = new Set(
26+
$metricsViewQuery.metricsView?.measures?.map((m) => m.name as string) ?? [],
27+
);
28+
29+
// Convert measures to KPI specs, filtering out any excluded by a security
30+
// policy (those will be absent from the metrics view's validSpec).
31+
$: kpis = (kpiGridProperties.measures || [])
32+
.filter(
33+
(measure) =>
34+
$metricsViewQuery.isLoading || accessibleMeasureNames.has(measure),
35+
)
36+
.map((measure) => ({
37+
metrics_view: kpiGridProperties.metrics_view,
38+
measure,
39+
sparkline: kpiGridProperties.sparkline,
40+
hide_time_range: kpiGridProperties.hide_time_range,
41+
comparison: kpiGridProperties.comparison,
42+
dimension_filters: kpiGridProperties.dimension_filters,
43+
time_filters: kpiGridProperties.time_filters,
44+
}));
3245
3346
$: filters = {
3447
time_filters: kpiGridProperties.time_filters,

web-common/src/features/canvas/components/leaderboard/selector.ts

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import type { LeaderboardSpec } from "@rilldata/web-common/features/canvas/components/leaderboard";
2-
import {
3-
validateDimensions,
4-
validateMeasures,
5-
} from "@rilldata/web-common/features/canvas/components/validators";
62
import type { V1MetricsViewSpec } from "@rilldata/web-common/runtime-client";
73

84
export function validateLeaderboardSchema(
@@ -35,8 +31,14 @@ export function validateLeaderboardSchema(
3531
const allDimensions =
3632
metricsView?.dimensions?.map((d) => d.name || (d.column as string)) || [];
3733

38-
let measures = leaderboardSpec?.measures || [];
39-
let dimensions = leaderboardSpec?.dimensions || [];
34+
// Filter to only accessible fields, silently dropping any excluded by a
35+
// security policy.
36+
const measures = (leaderboardSpec?.measures || []).filter((m) =>
37+
allMeasures.includes(m),
38+
);
39+
const dimensions = (leaderboardSpec?.dimensions || []).filter((d) =>
40+
allDimensions.includes(d),
41+
);
4042

4143
if (!measures.length || !dimensions.length) {
4244
return {
@@ -45,29 +47,6 @@ export function validateLeaderboardSchema(
4547
};
4648
}
4749

48-
measures = measures.filter((c) => allMeasures.includes(c));
49-
dimensions = dimensions.filter((c) => allDimensions.includes(c));
50-
51-
const validateMeasuresRes = validateMeasures(metricsView, measures);
52-
if (!validateMeasuresRes.isValid) {
53-
const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", ");
54-
return {
55-
isValid: false,
56-
error: `Invalid measure(s) "${invalidMeasures}" selected for the table`,
57-
};
58-
}
59-
60-
const validateDimensionsRes = validateDimensions(metricsView, dimensions);
61-
62-
if (!validateDimensionsRes.isValid) {
63-
const invalidDimensions =
64-
validateDimensionsRes.invalidDimensions.join(", ");
65-
66-
return {
67-
isValid: false,
68-
error: `Invalid dimension(s) "${invalidDimensions}" selected for the table`,
69-
};
70-
}
7150
return {
7251
isValid: true,
7352
error: undefined,

web-common/src/features/canvas/components/pivot/CanvasPivotDisplay.svelte

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import type { PivotCanvasComponent } from "@rilldata/web-common/features/canvas/components/pivot";
3+
import { isTimeDimension } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils";
34
import ComponentHeader from "../../ComponentHeader.svelte";
45
import CanvasPivotRenderer from "./CanvasPivotRenderer.svelte";
56
import { validateTableSchema } from "./selector";
@@ -36,27 +37,70 @@
3637
3738
$: _metricViewSpec = getMetricsViewFromName(tableSpec.metrics_view);
3839
$: metricsViewSpec = $_metricViewSpec.metricsView;
40+
$: metricsViewLoading = $_metricViewSpec.isLoading;
3941
40-
$: schema = validateTableSchema(metricsViewSpec, tableSpec);
42+
$: schema = validateTableSchema(metricsViewSpec, tableSpec, metricsViewLoading);
4143
$: widthScopeKey = `canvas:${component.parent.name}:${component.id}`;
4244
45+
// Build accessible field lists by filtering out any fields not present in the
46+
// metrics view spec (e.g. excluded by a security policy).
47+
$: accessibleColumns =
48+
"columns" in tableSpec
49+
? (tableSpec.columns || []).filter((c) => {
50+
const allMeasures =
51+
metricsViewSpec?.measures?.map((m) => m.name as string) || [];
52+
const allDimensions =
53+
metricsViewSpec?.dimensions?.map(
54+
(d) => d.name || (d.column as string),
55+
) || [];
56+
return allMeasures.includes(c) || allDimensions.includes(c);
57+
})
58+
: [];
59+
60+
$: accessibleMeasures =
61+
!("columns" in tableSpec)
62+
? (tableSpec.measures || []).filter((m) =>
63+
metricsViewSpec?.measures?.some((mv) => mv.name === m),
64+
)
65+
: [];
66+
67+
$: accessibleRowDimensions =
68+
!("columns" in tableSpec)
69+
? (tableSpec.row_dimensions || []).filter(
70+
(d) =>
71+
metricsViewSpec?.dimensions?.some(
72+
(mv) => mv.name === d || mv.column === d,
73+
) ||
74+
(metricsViewSpec?.timeDimension !== undefined &&
75+
isTimeDimension(d, metricsViewSpec.timeDimension)),
76+
)
77+
: [];
78+
79+
$: accessibleColDimensions =
80+
!("columns" in tableSpec)
81+
? (tableSpec.col_dimensions || []).filter(
82+
(d) =>
83+
metricsViewSpec?.dimensions?.some(
84+
(mv) => mv.name === d || mv.column === d,
85+
) ||
86+
(metricsViewSpec?.timeDimension !== undefined &&
87+
isTimeDimension(d, metricsViewSpec.timeDimension)),
88+
)
89+
: [];
90+
4391
$: if ("columns" in tableSpec && schema.isValid) {
44-
const columns = tableSpec?.columns || [];
4592
pivotState.update((state) => ({
4693
...state,
4794
sorting: [],
4895
expanded: {},
4996
activeCell: null,
5097
columnPage: 1,
5198
rowPage: 1,
52-
columns: tableFieldMapper(columns, metricsViewSpec),
99+
columns: tableFieldMapper(accessibleColumns, metricsViewSpec),
53100
showTotalsColumn: tableSpec.hide_totals_col !== true,
54101
showTotalsRow: tableSpec.hide_totals_row !== true,
55102
}));
56103
} else if (!("columns" in tableSpec) && schema.isValid) {
57-
const measures = tableSpec.measures || [];
58-
const colDimensions = tableSpec.col_dimensions || [];
59-
const rowDimensions = tableSpec.row_dimensions || [];
60104
pivotState.update((state) => ({
61105
...state,
62106
sorting: [],
@@ -65,10 +109,10 @@
65109
columnPage: 1,
66110
rowPage: 1,
67111
columns: [
68-
...tableFieldMapper(colDimensions, metricsViewSpec),
69-
...tableFieldMapper(measures, metricsViewSpec),
112+
...tableFieldMapper(accessibleColDimensions, metricsViewSpec),
113+
...tableFieldMapper(accessibleMeasures, metricsViewSpec),
70114
],
71-
rows: tableFieldMapper(rowDimensions, metricsViewSpec),
115+
rows: tableFieldMapper(accessibleRowDimensions, metricsViewSpec),
72116
showTotalsColumn: tableSpec.hide_totals_col !== true,
73117
showTotalsRow: tableSpec.hide_totals_row !== true,
74118
}));

web-common/src/features/canvas/components/pivot/CanvasPivotRenderer.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
export let schema: {
2222
isValid: boolean;
2323
error?: string;
24+
isLoading?: boolean;
2425
};
2526
export let pivotDataStore: PivotDataStore | undefined;
2627
export let pivotConfig: Readable<PivotDataStoreConfig> | undefined;

web-common/src/features/canvas/components/pivot/selector.ts

Lines changed: 32 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
import {
2-
validateDimensions,
3-
validateMeasures,
4-
} from "@rilldata/web-common/features/canvas/components/validators";
51
import { isTimeDimension } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils";
6-
import type { PivotSpec, TableSpec } from "./";
2+
import type { PivotSpec, TableSpec } from ".";
73
import type { V1MetricsViewSpec } from "@rilldata/web-common/runtime-client";
84

95
export function validateTableSchema(
106
metricsView: V1MetricsViewSpec | undefined,
117
tableSpec: PivotSpec | TableSpec,
8+
isLoading?: boolean,
129
): {
1310
isValid: boolean;
1411
error?: string;
12+
isLoading?: boolean;
1513
} {
14+
if (isLoading) {
15+
return { isValid: true, isLoading: true };
16+
}
17+
1618
if (!metricsView) {
1719
return {
1820
isValid: false,
@@ -31,82 +33,54 @@ function validateFlat(tableSpec: TableSpec, metricsView: V1MetricsViewSpec) {
3133
const allMeasures = metricsView?.measures?.map((m) => m.name as string) || [];
3234
const allDimensions =
3335
metricsView?.dimensions?.map((d) => d.name || (d.column as string)) || [];
34-
const columns = tableSpec?.columns || [];
3536

36-
const measures = columns.filter((c) => allMeasures.includes(c));
37-
const dimensions = columns.filter((c) => allDimensions.includes(c));
37+
// Filter columns to only those accessible in the metrics view, silently
38+
// dropping any excluded by a security policy.
39+
const accessibleColumns = (tableSpec?.columns || []).filter(
40+
(c) => allMeasures.includes(c) || allDimensions.includes(c),
41+
);
3842

39-
if (!columns.length) {
43+
if (!accessibleColumns.length) {
4044
return {
4145
isValid: false,
4246
error: "Select at least one measure or dimension for the table",
4347
};
4448
}
45-
const validateMeasuresRes = validateMeasures(metricsView, measures);
46-
if (!validateMeasuresRes.isValid) {
47-
const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", ");
48-
return {
49-
isValid: false,
50-
error: `Invalid measure(s) "${invalidMeasures}" selected for the table`,
51-
};
52-
}
53-
54-
const validateDimensionsRes = validateDimensions(metricsView, dimensions);
5549

56-
if (!validateDimensionsRes.isValid) {
57-
const invalidDimensions =
58-
validateDimensionsRes.invalidDimensions.join(", ");
59-
60-
return {
61-
isValid: false,
62-
error: `Invalid dimension(s) "${invalidDimensions}" selected for the table`,
63-
};
64-
}
6550
return {
6651
isValid: true,
6752
error: undefined,
6853
};
6954
}
7055

7156
function validatePivot(tableSpec: PivotSpec, metricsView: V1MetricsViewSpec) {
72-
const measures = tableSpec.measures || [];
73-
const rowDimensions = tableSpec.row_dimensions || [];
74-
const colDimensions = tableSpec.col_dimensions || [];
57+
const allMeasures = metricsView?.measures?.map((m) => m.name as string) || [];
58+
const allDimensions =
59+
metricsView?.dimensions?.map((d) => d.name || (d.column as string)) || [];
60+
61+
// Filter each list to only accessible fields, silently dropping any
62+
// excluded by a security policy.
63+
const measures = (tableSpec.measures || []).filter((m) =>
64+
allMeasures.includes(m),
65+
);
66+
const rowDimensions = (tableSpec.row_dimensions || []).filter(
67+
(d) =>
68+
allDimensions.includes(d) ||
69+
(metricsView.timeDimension && isTimeDimension(d, metricsView.timeDimension)),
70+
);
71+
const colDimensions = (tableSpec.col_dimensions || []).filter(
72+
(d) =>
73+
allDimensions.includes(d) ||
74+
(metricsView.timeDimension && isTimeDimension(d, metricsView.timeDimension)),
75+
);
7576

7677
if (!measures.length && !rowDimensions.length && !colDimensions.length) {
7778
return {
7879
isValid: false,
7980
error: "Select at least one measure or dimension for the table",
8081
};
8182
}
82-
const validateMeasuresRes = validateMeasures(metricsView, measures);
83-
if (!validateMeasuresRes.isValid) {
84-
const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", ");
85-
return {
86-
isValid: false,
87-
error: `Invalid measure(s) "${invalidMeasures}" selected for the table`,
88-
};
89-
}
90-
91-
const allDimensions = rowDimensions
92-
.concat(colDimensions)
93-
.filter(
94-
(d) =>
95-
!metricsView.timeDimension ||
96-
!isTimeDimension(d, metricsView.timeDimension),
97-
);
9883

99-
const validateDimensionsRes = validateDimensions(metricsView, allDimensions);
100-
101-
if (!validateDimensionsRes.isValid) {
102-
const invalidDimensions =
103-
validateDimensionsRes.invalidDimensions.join(", ");
104-
105-
return {
106-
isValid: false,
107-
error: `Invalid dimension(s) "${invalidDimensions}" selected for the table`,
108-
};
109-
}
11084
return {
11185
isValid: true,
11286
error: undefined,

0 commit comments

Comments
 (0)