You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by rb...@apache.org on 2026/10/23 14:51:40 UTC

(superset) branch master updated: feat(dashboard): expose authorized chart display labels (#45552)

This is an automated email from the ASF dual-hosted git repository.

rebenitez1802 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new d0116153376 feat(dashboard): expose authorized chart display labels (#45552)
d0116153376 is described below

commit d01161533766621619633ffd9dd4e7ac2c734ece
Author: Rafael Benitez <re...@gmail.com>
AuthorDate: Fri Oct 23 11:51:32 2026 -0300

    feat(dashboard): expose authorized chart display labels (#45552)
---
 docs/docs/using-superset/embedding.mdx             |   36 +
 docs/static/resources/openapi.json                 |   10 +
 .../superset-ui-core/src/query/types/Datasource.ts |    1 +
 .../test/chart/models/ChartProps.test.ts           |   15 +
 .../BigNumberPeriodOverPeriod/transformProps.ts    |    6 +-
 .../src/BigNumber/BigNumberTotal/transformProps.ts |    6 +-
 .../BigNumberWithTrendline/transformProps.ts       |    6 +-
 .../plugin-chart-echarts/src/BigNumber/utils.ts    |   10 +-
 .../test/BigNumber/chartDisplayLabels.test.ts      |  196 +++
 .../test/Timeseries/transformProps.test.ts         |   37 +
 .../test/transformProps.test.tsx                   |   58 +
 .../components/gridComponents/Chart/Chart.test.tsx |   55 +
 .../components/gridComponents/Chart/Chart.tsx      |    7 +-
 .../nativeFilters/FilterBar/utils.test.ts          |   35 +
 .../components/nativeFilters/FilterBar/utils.ts    |    5 +-
 superset-frontend/src/dashboard/types.ts           |    1 +
 .../dashboard/util/withChartDisplayLabels.test.ts  |  153 +++
 .../src/dashboard/util/withChartDisplayLabels.ts   |   61 +
 superset/connectors/sqla/models.py                 |  242 +++-
 superset/daos/dashboard.py                         |   18 +-
 superset/dashboards/api.py                         |   25 +-
 superset/dashboards/schemas.py                     |   13 +
 superset/models/dashboard.py                       |  225 +++-
 superset/security/manager.py                       |   64 +-
 tests/integration_tests/dashboards/api_tests.py    |  617 ++++++++-
 tests/integration_tests/fixtures/public_role.py    |   47 +-
 .../security/public_role_tests.py                  |   93 ++
 tests/unit_tests/dashboards/datasets_test.py       | 1381 +++++++++++++++++++-
 .../security/test_dashboard_chart_labels.py        |  207 +++
 29 files changed, 3593 insertions(+), 37 deletions(-)

diff --git a/docs/docs/using-superset/embedding.mdx b/docs/docs/using-superset/embedding.mdx
index a48b17e1b81..c765ba9359f 100644
--- a/docs/docs/using-superset/embedding.mdx
+++ b/docs/docs/using-superset/embedding.mdx
@@ -59,6 +59,42 @@ embedDashboard({
 
 `fetchGuestToken` must return a **guest token** obtained from your server by calling Superset's `/api/v1/security/guest_token/` endpoint with a service account. Do not call this endpoint from client-side code.
 
+## Chart display names
+
+Dashboard charts and cross-filter chips use configured column and metric display
+names even when a viewer cannot access the underlying SQL dataset directly. This
+also applies to embedded guests. Set a column or saved metric's verbose name in
+the dataset editor, or supply a custom label in the chart editor. Fields without
+a configured display name keep their original names.
+
+For viewers without dataset access, the dashboard datasets API supplies these
+names in `chart_display_labels`, keyed by chart ID and then by field or metric
+identifier. Viewers with dataset access use the existing `verbose_map`; the API
+omits `chart_display_labels` and skips the per-chart checks needed to generate it.
+Each entry contains labels from recognized saved display controls on an accessible
+chart on that dashboard. Filter-only fields, sort-only fields, expression
+internals, and dataset fields absent from those controls are excluded. A background
+query-context refresh cannot add labels for other fields. Table, AG Grid, and
+Handlebars charts exclude controls hidden by their saved query mode; Handlebars
+does not use percentage metrics. Other visualizations use the shared list of
+recognized control names without checking whether their plugin renders each
+control. Legacy, API-created, or imported charts can therefore retain labels for
+unused saved controls, such as `all_columns` left over on a pie chart. Generated
+identifiers such as `__timestamp` keep their existing fallback labels.
+Guest tokens must authorize the dashboard and, when a `datasets`
+allowlist is present, the SQL dataset. Semantic views do not supply this map.
+
+Custom security managers can specialize
+`can_access_dashboard_chart_labels(dashboard, chart, *, guest_dashboard_access=None, datasource_access=None)`
+to restrict label access for callers without datasource access. The hook is skipped
+for callers with datasource access, who receive the existing `verbose_map`; an
+override of this hook cannot restrict that map. The default implementation also
+honors existing `can_access_chart` and `raise_for_access` overrides and the
+`EXTRA_RAISE_FOR_ACCESS_BYPASS` configuration. For the built-in security manager,
+guest checks reuse the dashboard grant. Non-guest checks reuse the request's denied
+datasource decision and prefetched chart editors and viewers without repeating
+datasource permission queries for every chart.
+
 ---
 
 ## Callbacks
diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json
index 7baeebd9409..e6579ed648b 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -3856,6 +3856,16 @@
           "cache_timeout": {
             "type": "integer"
           },
+          "chart_display_labels": {
+            "additionalProperties": {
+              "additionalProperties": {
+                "type": "string"
+              },
+              "type": "object"
+            },
+            "description": "Display labels keyed by member chart ID, then by the raw column or metric ID that chart uses. Only charts the caller can access and labels configured for their saved dimensions and metrics are included; omitted for semantic views, when there are none, or when the caller has datasource access and already receives verbose_map.",
+            "type": "object"
+          },
           "column_formats": {
             "type": "object"
           },
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts
index 4b51f8a23cd..52700e9d5ab 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts
@@ -68,6 +68,7 @@ export interface Datasource {
   verboseMap?: {
     [key: string]: string;
   };
+  chartDisplayLabels?: Record<string, Record<string, string>>;
   currencyCodeColumn?: string;
 }
 
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts b/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
index afa46ded88e..1d921a86cd8 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
+++ b/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
@@ -170,3 +170,18 @@ test('preserves an optional shared color scale through construction and selector
     selector({ ...config, colorScale: undefined }).colorScale,
   ).toBeUndefined();
 });
+
+test('camelizes chart display labels only at the datasource top level', () => {
+  const chartDisplayLabels = {
+    '12': { country_name: 'Country', sum__SP_POP_TOTL: 'Population' },
+  };
+  const datasource = { chart_display_labels: chartDisplayLabels };
+  const props = new ChartProps({ datasource, theme: supersetTheme });
+
+  expect(props.datasource.chartDisplayLabels).toBe(chartDisplayLabels);
+  expect(props.datasource.chartDisplayLabels?.['12']).toEqual({
+    country_name: 'Country',
+    sum__SP_POP_TOTL: 'Population',
+  });
+  expect(props.rawDatasource).toBe(datasource);
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts
index 70e8702fb8d..7e87d71ea66 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts
@@ -109,7 +109,11 @@ export default function transformProps(chartProps: ChartProps) {
   const data = dataA;
   const metricName = metric ? getMetricLabel(metric) : '';
   const metrics = chartProps.datasource?.metrics || [];
-  const originalLabel = getOriginalLabel(metric, metrics);
+  const originalLabel = getOriginalLabel(
+    metric,
+    metrics,
+    chartProps.datasource?.verboseMap,
+  );
   const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
 
   const dashboardTimeCompare = formData?.extraFormData?.time_compare;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts
index 2d7df441c31..e48f463192a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts
@@ -73,7 +73,11 @@ export default function transformProps(
   } = queriesData[0] || {};
   const granularity = extractTimegrain(rawFormData as QueryFormData);
   const metrics = chartProps.datasource?.metrics || [];
-  const originalLabel = getOriginalLabel(metric, metrics);
+  const originalLabel = getOriginalLabel(
+    metric,
+    metrics,
+    chartProps.datasource?.verboseMap,
+  );
   const metricName = getMetricLabel(metric);
   const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
   const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts
index 9b4a4640e74..fc7345f17a3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts
@@ -137,7 +137,11 @@ export default function transformProps(
   const refs: Refs = {};
   const metricName = getMetricLabel(metric);
   const metrics = chartProps.datasource?.metrics || [];
-  const originalLabel = getOriginalLabel(metric, metrics);
+  const originalLabel = getOriginalLabel(
+    metric,
+    metrics,
+    chartProps.datasource?.verboseMap,
+  );
   const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
   const compareLag = Number(compareLag_) || 0;
   let formattedSubheader = subheader;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/utils.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/utils.ts
index bfb3f2061be..e2ef1acc048 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/utils.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/utils.ts
@@ -54,14 +54,20 @@ export const getDateFormatter = (
 export function getOriginalLabel(
   metric: QueryFormMetric,
   metrics: Metric[] = [],
+  verboseMap: Record<string, string> = {},
 ): string {
   const metricLabel = typeof metric === 'string' ? metric : metric.label || '';
 
   if (isSavedMetric(metric)) {
     const metricEntry = metrics.find(m => m.metric_name === metric);
+    if (metricEntry) {
+      return metricEntry.verbose_name || metric;
+    }
+    const verboseName = Object.prototype.hasOwnProperty.call(verboseMap, metric)
+      ? verboseMap[metric]
+      : undefined;
     return (
-      metricEntry?.verbose_name ||
-      metricEntry?.metric_name ||
+      (typeof verboseName === 'string' ? verboseName : undefined) ||
       metric ||
       'Unknown Metric'
     );
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/chartDisplayLabels.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/chartDisplayLabels.test.ts
new file mode 100644
index 00000000000..b6492e587c9
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/chartDisplayLabels.test.ts
@@ -0,0 +1,196 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ChartProps, VizType } from '@superset-ui/core';
+import { supersetTheme } from '@apache-superset/core/theme';
+import { withChartDisplayLabels } from 'src/dashboard/util/withChartDisplayLabels';
+import transformTotal from '../../src/BigNumber/BigNumberTotal/transformProps';
+import transformTrendline from '../../src/BigNumber/BigNumberWithTrendline/transformProps';
+import transformPeriodOverPeriod from '../../src/BigNumber/BigNumberPeriodOverPeriod/transformProps';
+import type {
+  BigNumberTotalChartProps,
+  BigNumberWithTrendlineChartProps,
+} from '../../src/BigNumber/types';
+import { getOriginalLabel } from '../../src/BigNumber/utils';
+
+const transforms = [
+  ['total', transformTotal],
+  ['trendline', transformTrendline],
+  ['period over period', transformPeriodOverPeriod],
+] as const;
+
+test.each(transforms)(
+  '%s shows authorized saved-metric labels without dataset metadata',
+  (name, transformProps) => {
+    const datasource = {
+      chart_display_labels: {
+        '12': { sum__SP_POP_TOTL: 'Population' },
+        '13': { sum__SP_POP_TOTL: 'Other chart label' },
+      },
+    };
+    const data = [{ sum__SP_POP_TOTL: 1000, __timestamp: 0 }];
+    const chartProps = new ChartProps({
+      datasource: withChartDisplayLabels(datasource, 12),
+      formData: {
+        datasource: '1__table',
+        viz_type: VizType.BigNumber,
+        metric: 'sum__SP_POP_TOTL',
+        show_metric_name: true,
+        x_axis: '__timestamp',
+        y_axis_format: '.0f',
+      },
+      queriesData: [{ data }],
+      theme: supersetTheme,
+    });
+
+    const result = transformProps(
+      chartProps as unknown as BigNumberTotalChartProps &
+        BigNumberWithTrendlineChartProps,
+    );
+
+    expect(chartProps.datasource.metrics).toBeUndefined();
+    expect(result.metricName).toBe('Population');
+    expect(result.showMetricName).toBe(true);
+    expect(result.bigNumber).toEqual(
+      name === 'period over period' ? '1000' : 1000,
+    );
+    expect(chartProps.queriesData[0].data).toEqual([
+      { sum__SP_POP_TOTL: 1000, __timestamp: 0 },
+    ]);
+    expect(chartProps.rawFormData.metric).toBe('sum__SP_POP_TOTL');
+  },
+);
+
+test.each([undefined, ''])(
+  'preserves the raw saved metric when its verbose name is %s',
+  verboseName => {
+    expect(
+      getOriginalLabel(
+        'count',
+        [
+          {
+            metric_name: 'count',
+            uuid: 'metric-uuid',
+            verbose_name: verboseName,
+          },
+        ],
+        { count: 'Row count' },
+      ),
+    ).toBe('count');
+  },
+);
+
+test('uses the verbose map when the saved metric metadata is absent', () => {
+  expect(
+    getOriginalLabel('sum__SP_POP_TOTL', [], {
+      sum__SP_POP_TOTL: 'Population',
+    }),
+  ).toBe('Population');
+});
+
+test.each(['sum__SP_POP_TOTL', 'constructor', '__proto__', 'toString'])(
+  'falls back to the raw saved metric %s without a display label',
+  metric => {
+    expect(getOriginalLabel(metric)).toBe(metric);
+  },
+);
+
+test.each(transforms)(
+  '%s preserves an unlabelled saved metric beside a labelled column with the same name',
+  (name, transformProps) => {
+    const data = [{ count: 1000, __timestamp: 0 }];
+    const chartProps = new ChartProps({
+      datasource: {
+        metrics: [{ metric_name: 'count' }],
+        columns: [{ column_name: 'count', verbose_name: 'Row count' }],
+        verbose_map: { count: 'Row count' },
+      },
+      formData: {
+        datasource: '1__table',
+        viz_type: VizType.BigNumber,
+        metric: 'count',
+        show_metric_name: true,
+        x_axis: '__timestamp',
+        y_axis_format: '.0f',
+      },
+      queriesData: [{ data }],
+      theme: supersetTheme,
+    });
+
+    const result = transformProps(
+      chartProps as unknown as BigNumberTotalChartProps &
+        BigNumberWithTrendlineChartProps,
+    );
+
+    expect(result.metricName).toBe('count');
+    expect(result.bigNumber).toEqual(
+      name === 'period over period' ? '1000' : 1000,
+    );
+    expect(chartProps.queriesData[0].data).toEqual(data);
+    expect(chartProps.rawFormData.metric).toBe('count');
+  },
+);
+
+test('uses configured labels for prototype-like saved metric names', () => {
+  expect(
+    getOriginalLabel('constructor', [], { constructor: 'Population' }),
+  ).toBe('Population');
+});
+
+test('ignores a non-string verbose map entry', () => {
+  expect(
+    getOriginalLabel('sum__SP_POP_TOTL', [], {
+      sum__SP_POP_TOTL: 123,
+    } as unknown as Record<string, string>),
+  ).toBe('sum__SP_POP_TOTL');
+});
+
+test.each(transforms)(
+  '%s preserves existing metric metadata over chart display labels',
+  (_name, transformProps) => {
+    const chartProps = new ChartProps({
+      datasource: withChartDisplayLabels(
+        {
+          metrics: [
+            {
+              metric_name: 'sum__SP_POP_TOTL',
+              verbose_name: 'Dataset population',
+            },
+          ],
+          chart_display_labels: { '12': { sum__SP_POP_TOTL: 'Population' } },
+        },
+        12,
+      ),
+      formData: {
+        datasource: '1__table',
+        viz_type: VizType.BigNumber,
+        metric: 'sum__SP_POP_TOTL',
+        show_metric_name: true,
+      },
+      queriesData: [{ data: [] }],
+      theme: supersetTheme,
+    });
+
+    const result = transformProps(
+      chartProps as unknown as BigNumberTotalChartProps &
+        BigNumberWithTrendlineChartProps,
+    );
+
+    expect(result.metricName).toBe('Dataset population');
+  },
+);
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
index a9ecd0e2c37..07b8cf3f015 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
@@ -37,6 +37,7 @@ import {
 } from '@superset-ui/core';
 import { GenericDataType } from '@apache-superset/core/common';
 import { supersetTheme } from '@apache-superset/core/theme';
+import { withChartDisplayLabels } from 'src/dashboard/util/withChartDisplayLabels';
 import { init, type SeriesOption } from 'echarts';
 import type { GridComponentOption } from 'echarts/components';
 import transformProps from '../../src/Timeseries/transformProps';
@@ -4644,6 +4645,42 @@ test('tooltip keeps the metric format on a Difference time comparison', () => {
   expect(result).toContain('$ 25');
 });
 
+test('uses authorized chart display labels in timeseries legends and tooltips', () => {
+  const datasource = withChartDisplayLabels(
+    { chart_display_labels: { '12': { sum__num: 'Population' } } },
+    12,
+  );
+  const normalizedProps = new ChartProps({ datasource, theme: supersetTheme });
+  const chartProps = createTestChartProps({
+    formData: { metrics: ['sum__num'], richTooltip: true },
+    queriesData: [
+      createTestQueryData([{ sum__num: 1000, __timestamp: BASE_TIMESTAMP }], {
+        label_map: { sum__num: ['sum__num'] },
+      }),
+    ],
+    datasource: normalizedProps.datasource,
+  });
+
+  const { echartOptions } = transformProps(chartProps);
+  expect(echartOptions.series).toEqual(
+    expect.arrayContaining([expect.objectContaining({ name: 'Population' })]),
+  );
+  const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
+  expect(
+    tooltip.formatter([
+      {
+        seriesId: 'Population',
+        seriesName: 'Population',
+        value: [BASE_TIMESTAMP, 1000],
+      },
+    ]),
+  ).toContain('Population');
+  expect(chartProps.queriesData[0].data[0]).toEqual({
+    sum__num: 1000,
+    __timestamp: BASE_TIMESTAMP,
+  });
+});
+
 test('applies user-defined minimum interval to both axes', () => {
   const queriesData: ChartDataResponseResult[] = [
     createTestQueryData(
diff --git a/superset-frontend/plugins/plugin-chart-table/test/transformProps.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/transformProps.test.tsx
new file mode 100644
index 00000000000..ee4098cd028
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-table/test/transformProps.test.tsx
@@ -0,0 +1,58 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ChartProps } from '@superset-ui/core';
+import { supersetTheme } from '@apache-superset/core/theme';
+import { render, screen } from '@superset-ui/core/spec';
+import { withChartDisplayLabels } from 'src/dashboard/util/withChartDisplayLabels';
+import TableChart from '../src/TableChart';
+import transformProps from '../src/transformProps';
+import testData from './testData';
+
+test('renders authorized chart display labels in table headers without changing data keys', () => {
+  const datasource = withChartDisplayLabels(
+    {
+      chart_display_labels: {
+        '12': { name: 'Person', sum__num: 'Population' },
+        '13': { 'abc.com': 'Other chart column' },
+      },
+    },
+    12,
+  );
+  const chartProps = new ChartProps({ datasource, theme: supersetTheme });
+  const transformed = transformProps({
+    ...testData.basic,
+    datasource: chartProps.datasource,
+  });
+
+  render(<TableChart {...transformed} sticky={false} />);
+
+  expect(
+    screen.getByRole('columnheader', { name: /^Person/ }),
+  ).toBeInTheDocument();
+  expect(
+    screen.getByRole('columnheader', { name: /^Population/ }),
+  ).toBeInTheDocument();
+  expect(screen.queryByText('Other chart column')).not.toBeInTheDocument();
+  expect(transformed.columns.map(column => column.key)).toEqual(
+    testData.basic.queriesData[0].colnames,
+  );
+  expect(transformed.data[0]).toEqual(
+    expect.objectContaining({ name: 'Michael', sum__num: 2467063 }),
+  );
+});
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx
index 775e28f5aaa..c60c23355f6 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx
@@ -1200,3 +1200,58 @@ test('a hidden chart does not adopt a prop update, and adopts it once revealed',
   rerender(<Chart {...props} isComponentVisible isInView />);
   expect(capturedChartContainerProps.isInView).toBe(true);
 });
+
+test('merges only the current chart labels into the ChartContainer verbose map', () => {
+  const datasourceId = chartQueries[queryId].form_data.datasource;
+  const datasource = {
+    ...mockDatasource['7__table'],
+    verbose_map: undefined,
+    chart_display_labels: {
+      [queryId]: { country_name: 'Country', sum__num: 'Population' },
+      [queryId + 1]: { other_column: 'Other chart' },
+    },
+  };
+  setup({}, { datasources: { [datasourceId]: datasource } });
+
+  expect(capturedChartContainerProps.datasource).toEqual({
+    ...datasource,
+    verbose_map: { country_name: 'Country', sum__num: 'Population' },
+  });
+  expect(datasource.verbose_map).toBeUndefined();
+  expect(capturedChartContainerProps.formData).toEqual(
+    expect.objectContaining({ datasource: datasourceId }),
+  );
+});
+
+test('keeps the merged datasource reference when a labelled chart rerenders', () => {
+  const datasourceId = chartQueries[queryId].form_data.datasource;
+  const datasource = {
+    ...mockDatasource['7__table'],
+    verbose_map: undefined,
+    chart_display_labels: {
+      [queryId]: { country_name: 'Country' },
+    },
+  };
+  const { rerender } = setup(
+    { isComponentVisible: true, isInView: false },
+    { datasources: { [datasourceId]: datasource } },
+  );
+  const mergedDatasource = capturedChartContainerProps.datasource;
+  expect(mergedDatasource).not.toBe(datasource);
+  expect(mergedDatasource).toEqual({
+    ...datasource,
+    verbose_map: { country_name: 'Country' },
+  });
+
+  rerender(<Chart {...props} isComponentVisible isInView />);
+
+  expect(capturedChartContainerProps.isInView).toBe(true);
+  expect(capturedChartContainerProps.datasource).toBe(mergedDatasource);
+});
+
+test('preserves the datasource reference when no chart display labels are present', () => {
+  setup();
+  expect(capturedChartContainerProps.datasource).toBe(
+    mockDatasource['7__table'],
+  );
+});
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
index 1e7daf79748..6fe9e4a49bb 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
@@ -54,6 +54,7 @@ import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
 import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
 import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
 import type { RootState, Datasource, Slice } from 'src/dashboard/types';
+import { withChartDisplayLabels } from 'src/dashboard/util/withChartDisplayLabels';
 import {
   convertChartStateToOwnState,
   hasChartStateConverter,
@@ -251,6 +252,10 @@ const Chart = (props: ChartProps) => {
         state.datasources[chart.form_data.datasource]) ||
       PLACEHOLDER_DATASOURCE,
   );
+  const chartDatasource = useMemo(
+    () => withChartDisplayLabels(datasource, props.id),
+    [datasource, props.id],
+  );
   const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo);
   const showChartTimestamps: boolean = useSelector(
     (state: RootState) =>
@@ -864,7 +869,7 @@ const Chart = (props: ChartProps) => {
           chartId={props.id}
           chartStatus={chartStatus ?? undefined}
           chartStackTrace={chart.chartStackTrace ?? undefined}
-          datasource={datasource}
+          datasource={chartDatasource}
           dashboardId={props.dashboardId}
           initialValues={EMPTY_OBJECT}
           formData={
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts
index 07968cb9683..8f0394f9d97 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.test.ts
@@ -24,7 +24,10 @@ import {
   Filter,
   FilterState,
 } from '@superset-ui/core';
+import { renderHook } from '@testing-library/react';
+import { createWrapper } from 'spec/helpers/testing-library';
 import {
+  useChartsVerboseMaps,
   checkIsApplyDisabled,
   checkIsValidateError,
   checkIsMissingRequiredValue,
@@ -878,3 +881,35 @@ test('clearedCustomizationTarget tolerates an absent target', () => {
     datasetId: undefined,
   });
 });
+
+test('cross-filter verbose maps use each chart own labels on a shared datasource', () => {
+  const datasource = {
+    verbose_map: { shared_column: 'Dataset label' },
+    chart_display_labels: {
+      '12': { country_name: 'Country', shared_column: 'Chart label' },
+      '13': { sum__num: 'Population' },
+    },
+  };
+  const { result } = renderHook(() => useChartsVerboseMaps(), {
+    wrapper: createWrapper({
+      useRedux: true,
+      initialState: {
+        datasources: { '7__table': datasource },
+        sliceEntities: {
+          slices: {
+            12: { form_data: { datasource: '7__table' } },
+            13: { form_data: { datasource: '7__table' } },
+            14: { form_data: { datasource: 'missing__table' } },
+          },
+        },
+      },
+    }),
+  });
+
+  expect(result.current).toEqual({
+    '12': { country_name: 'Country', shared_column: 'Dataset label' },
+    '13': { sum__num: 'Population', shared_column: 'Dataset label' },
+    '14': {},
+  });
+  expect(datasource.verbose_map).toEqual({ shared_column: 'Dataset label' });
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts
index a1569aceaa2..06cc29f8107 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts
@@ -30,6 +30,7 @@ import { createSelector } from '@reduxjs/toolkit';
 import { areObjectsEqual } from 'src/reduxUtils';
 import { testWithId } from 'src/utils/testUtils';
 import { RootState } from 'src/dashboard/types';
+import { withChartDisplayLabels } from 'src/dashboard/util/withChartDisplayLabels';
 import { FilterElement } from './FilterControls/types';
 
 export const getOnlyExtraFormData = (
@@ -159,7 +160,9 @@ const chartsVerboseMapSelector = createSelector(
         : undefined;
       return {
         ...chartsVerboseMaps,
-        [chartId]: chartDatasource ? chartDatasource.verbose_map : {},
+        [chartId]: chartDatasource
+          ? withChartDisplayLabels(chartDatasource, chartId).verbose_map
+          : {},
       };
     }, {}),
 );
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 3b8dbecd097..37cab5f5c84 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -245,6 +245,7 @@ export type ChartsState = { [key: string]: Chart };
 
 export type Datasource = Dataset & {
   uid: string;
+  chart_display_labels?: Record<string, Record<string, string>>;
   column_types: GenericDataType[];
   table_name: string;
   database?: Database;
diff --git a/superset-frontend/src/dashboard/util/withChartDisplayLabels.test.ts b/superset-frontend/src/dashboard/util/withChartDisplayLabels.test.ts
new file mode 100644
index 00000000000..792bab6c9e6
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/withChartDisplayLabels.test.ts
@@ -0,0 +1,153 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { Datasource } from 'src/dashboard/types';
+import { withChartDisplayLabels } from './withChartDisplayLabels';
+
+type LabelDatasource = Partial<
+  Pick<Datasource, 'chart_display_labels' | 'verbose_map'>
+>;
+
+test('adds only the requested chart labels without mutating the shared datasource', () => {
+  const labels = Object.freeze({ country_name: 'Country', sum__num: 'Total' });
+  const datasource: LabelDatasource = Object.freeze({
+    chart_display_labels: Object.freeze({
+      '12': labels,
+      '13': Object.freeze({ private_column: 'Other chart' }),
+    }),
+  });
+
+  const result = withChartDisplayLabels(datasource, 12);
+
+  expect(result).not.toBe(datasource);
+  expect(result.verbose_map).toEqual(labels);
+  expect(result.chart_display_labels).toBe(datasource.chart_display_labels);
+  expect(datasource.verbose_map).toBeUndefined();
+  expect(withChartDisplayLabels(datasource, '13').verbose_map).toEqual({
+    private_column: 'Other chart',
+  });
+});
+
+test('preserves dataset labels and returns the same reference when they cover chart labels', () => {
+  const datasource = {
+    verbose_map: Object.freeze({ country_name: 'Dataset country' }),
+    chart_display_labels: { '12': { country_name: 'Chart country' } },
+  };
+
+  expect(withChartDisplayLabels(datasource, 12)).toBe(datasource);
+  const extended = {
+    ...datasource,
+    chart_display_labels: {
+      '12': { country_name: 'Chart country', sum__num: 'Total' },
+    },
+  };
+  expect(withChartDisplayLabels(extended, 12).verbose_map).toEqual({
+    country_name: 'Dataset country',
+    sum__num: 'Total',
+  });
+});
+
+test.each<LabelDatasource['chart_display_labels']>([
+  undefined,
+  {},
+  { '13': { country_name: 'Country' } },
+  { '12': {} },
+])(
+  'keeps the datasource reference when no labels apply: %j',
+  chartDisplayLabels => {
+    const datasource = { chart_display_labels: chartDisplayLabels };
+    expect(withChartDisplayLabels(datasource, 12)).toBe(datasource);
+  },
+);
+
+test('ignores blank and non-string labels while preserving nonblank strings exactly', () => {
+  const datasource = {
+    chart_display_labels: {
+      '12': {
+        country_name: ' Country ',
+        empty: '',
+        whitespace: '  \t',
+        number: 123,
+        null: null,
+        object: {},
+      },
+    },
+  } as unknown as LabelDatasource;
+
+  expect(withChartDisplayLabels(datasource, 12).verbose_map).toEqual({
+    country_name: ' Country ',
+  });
+});
+
+test.each([null, 'Country', ['Country'], { blank: ' ' }, { number: 123 }])(
+  'ignores unusable chart entries and retains identity: %j',
+  labels => {
+    const datasource = {
+      chart_display_labels: { '12': labels },
+    } as unknown as LabelDatasource;
+    expect(withChartDisplayLabels(datasource, 12)).toBe(datasource);
+  },
+);
+
+test('does not use inherited chart entries or inherited raw labels', () => {
+  const inheritedChart: Record<string, Record<string, string>> = Object.create({
+    '12': { country_name: 'Country' },
+  });
+  const datasource = { chart_display_labels: inheritedChart };
+  expect(withChartDisplayLabels(datasource, 12)).toBe(datasource);
+
+  const inheritedLabel: Record<string, string> = Object.create({
+    hidden: 'Inherited',
+  });
+  inheritedLabel.country_name = 'Country';
+  const ownDatasource: LabelDatasource = {
+    chart_display_labels: { '12': inheritedLabel },
+  };
+  expect(withChartDisplayLabels(ownDatasource, 12).verbose_map).toEqual({
+    country_name: 'Country',
+  });
+});
+
+test.each(['__proto__', 'constructor'])(
+  'ignores the reserved chart ID %s even when it is an own property',
+  chartId => {
+    const datasource = {
+      chart_display_labels: { [chartId]: { country_name: 'Country' } },
+    };
+    expect(withChartDisplayLabels(datasource, chartId)).toBe(datasource);
+  },
+);
+
+test('preserves punctuation and prototype-like raw field IDs as own data properties', () => {
+  const labels = {
+    sum__SP_POP_TOTL: 'Population',
+    'country.name': 'Country',
+    ['__proto__']: 'A column',
+    constructor: 'Another column',
+  };
+  const datasource: LabelDatasource = {
+    chart_display_labels: { '12': labels },
+  };
+  const result = withChartDisplayLabels(datasource, 12);
+
+  expect(result.verbose_map).toEqual(labels);
+  expect(Object.getPrototypeOf(result.verbose_map)).toBe(Object.prototype);
+  expect(
+    Object.prototype.hasOwnProperty.call(result.verbose_map, '__proto__'),
+  ).toBe(true);
+});
diff --git a/superset-frontend/src/dashboard/util/withChartDisplayLabels.ts b/superset-frontend/src/dashboard/util/withChartDisplayLabels.ts
new file mode 100644
index 00000000000..7fdb511718a
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/withChartDisplayLabels.ts
@@ -0,0 +1,61 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { Datasource } from 'src/dashboard/types';
+
+/** Add authorized labels for one chart without changing shared dataset metadata. */
+export function withChartDisplayLabels<
+  T extends Partial<Pick<Datasource, 'chart_display_labels' | 'verbose_map'>>,
+>(datasource: T, chartId: number | string): T {
+  const chartKey = String(chartId);
+  const labelsByChart = datasource.chart_display_labels;
+  if (
+    chartKey === '__proto__' ||
+    chartKey === 'constructor' ||
+    !labelsByChart ||
+    !Object.prototype.hasOwnProperty.call(labelsByChart, chartKey)
+  ) {
+    return datasource;
+  }
+
+  const labels = labelsByChart[chartKey];
+  if (!labels || typeof labels !== 'object' || Array.isArray(labels)) {
+    return datasource;
+  }
+
+  const additionalLabels = Object.entries(labels).filter(
+    ([rawFieldId, label]) =>
+      typeof label === 'string' &&
+      label.trim().length > 0 &&
+      !Object.prototype.hasOwnProperty.call(
+        datasource.verbose_map ?? {},
+        rawFieldId,
+      ),
+  );
+  if (additionalLabels.length === 0) {
+    return datasource;
+  }
+
+  return {
+    ...datasource,
+    verbose_map: {
+      ...Object.fromEntries(additionalLabels),
+      ...datasource.verbose_map,
+    },
+  };
+}
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 6247a95b7f8..5d20be6f2e4 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -219,6 +219,88 @@ COLUMN_FORM_DATA_PARAMS = [
     "series",
 ]
 
+# Display controls exclude filter and sort dependencies, which still belong in
+# the full-access dataset payload but must not contribute chart display labels.
+DISPLAY_METRIC_LIST_PARAMS = (
+    "metrics",
+    "metrics_b",
+    "percent_metrics",
+    "tooltip_metrics",
+)
+DISPLAY_METRIC_PARAMS = (
+    "metric",
+    "metric_2",
+    "secondary_metric",
+    "size",
+    "x",
+    "y",
+    "left_metric",
+    "right_metric",
+    "open",
+    "close",
+    "high",
+    "low",
+)
+DISPLAY_COLUMN_LIST_PARAMS = (
+    "all_columns",
+    "columns",
+    "groupby",
+    "groupby_b",
+    "groupbyRows",
+    "groupbyColumns",
+    "series_columns",
+    "tooltip_columns",
+)
+DISPLAY_COLUMN_PARAMS = (
+    "all_columns_x",
+    "all_columns_y",
+    "entity",
+    "series",
+    "x_axis",
+    "source",
+    "target",
+    "source_category",
+    "target_category",
+    "column",
+    "start_time",
+    "end_time",
+    "y_axis",
+    "dimension",
+)
+# These controls accept either one column or a legacy list of columns.
+DISPLAY_COLUMN_MIXED_PARAMS = ("all_columns_x", "series")
+
+DisplayReference = str | dict[str, Any]
+ChartDisplayReferences = tuple[list[DisplayReference], list[DisplayReference]]
+
+
+def _authored_display_label(reference: dict[str, Any]) -> str | None:
+    """Return an explicitly authored adhoc label without interpreting SQL."""
+    label = reference.get("label")
+    if (
+        reference.get("hasCustomLabel") is True
+        and isinstance(label, str)
+        and label.strip()
+    ):
+        return label
+    return None
+
+
+def _physical_display_column(reference: dict[str, Any]) -> str | None:
+    """Recognize only explicit, exact physical-column references."""
+    label = reference.get("label")
+    if (
+        (
+            reference.get("isColumnReference") is True
+            or reference.get("columnType") == "BASE_AXIS"
+        )
+        and isinstance(label, str)
+        and label.strip()
+        and reference.get("sqlExpression") == label
+    ):
+        return label
+    return None
+
 
 class DatasourceKind(StrEnum):
     VIRTUAL = "virtual"
@@ -652,6 +734,131 @@ class BaseDatasource(
 
         return column_names or None
 
+    @staticmethod
+    def _query_output_references(
+        query_context: str | None,
+    ) -> ChartDisplayReferences:
+        """Collect query outputs for full-access metadata, including sort fields.
+
+        These references must not authorize chart labels: viewers can regenerate
+        the query context, and plugins add dependencies that they do not display.
+        """
+        columns: list[DisplayReference] = []
+        metrics: list[DisplayReference] = []
+        try:
+            payload = json.loads(query_context or "{}")
+        except (TypeError, ValueError):
+            return columns, metrics
+        if not isinstance(payload, dict) or not isinstance(
+            queries := payload.get("queries"), list
+        ):
+            return columns, metrics
+        for query in queries:
+            if not isinstance(query, dict):
+                continue
+            for name, target in (
+                ("columns", columns),
+                ("groupby", columns),
+                ("metrics", metrics),
+            ):
+                if isinstance(references := query.get(name), list):
+                    target.extend(
+                        item for item in references if isinstance(item, (str, dict))
+                    )
+        return columns, metrics
+
+    @staticmethod
+    def _active_display_form_data(
+        form_data: dict[str, Any], viz_type: str | None
+    ) -> dict[str, Any]:
+        """Exclude table controls retained while their query mode is hidden."""
+        if viz_type not in ("table", "ag-grid-table", "handlebars"):
+            return form_data
+        mode = form_data.get("query_mode")
+        if viz_type == "handlebars" and mode not in ("raw", "aggregate"):
+            # Handlebars' extractQueryFields retains both modes when unspecified.
+            params: tuple[str, ...] = ("all_columns", "columns", "groupby", "metrics")
+            return {name: form_data[name] for name in params if name in form_data}
+        if mode not in ("raw", "aggregate"):
+            # Match both table plugins' getQueryMode for legacy saved charts.
+            mode = "raw" if form_data.get("all_columns") else "aggregate"
+        params = (
+            ("all_columns", "columns")
+            if mode == "raw"
+            else ("groupby", "metrics", "percent_metrics")
+        )
+        return {
+            name: form_data[name]
+            for name in params
+            if name in form_data
+            and (viz_type != "handlebars" or name != "percent_metrics")
+        }
+
+    @classmethod
+    def _form_display_references(
+        cls, form_data: dict[str, Any], viz_type: str | None
+    ) -> ChartDisplayReferences:
+        """Collect recognized saved display controls without reading SQL.
+
+        List controls require lists. The single-column controls ``series`` and
+        ``all_columns_x`` also support their established legacy list shapes.
+        Control names are shared across visualizations, not validated against
+        each plugin's control panel or rendered fields.
+        """
+        columns: list[DisplayReference] = []
+        metrics: list[DisplayReference] = []
+        column_params: tuple[str, ...] = DISPLAY_COLUMN_PARAMS
+        if viz_type == "tree_chart":
+            column_params += ("id", "parent", "name")
+        for target, list_params, single_params in (
+            (columns, DISPLAY_COLUMN_LIST_PARAMS, column_params),
+            (metrics, DISPLAY_METRIC_LIST_PARAMS, DISPLAY_METRIC_PARAMS),
+        ):
+            for name in list_params:
+                if isinstance(references := form_data.get(name), list):
+                    target.extend(
+                        item for item in references if isinstance(item, (str, dict))
+                    )
+            for name in single_params:
+                if isinstance(value := form_data.get(name), (str, dict)):
+                    target.append(value)
+        for name in DISPLAY_COLUMN_MIXED_PARAMS:
+            if isinstance(references := form_data.get(name), list):
+                columns.extend(
+                    item for item in references if isinstance(item, (str, dict))
+                )
+
+        # Tooltip contents are explicit display references. Passing no currency
+        # column excludes the query-only currency-formatting dependency.
+        columns.extend(sorted(cls._extract_form_data_columns(form_data, None, False)))
+        return columns, metrics
+
+    @staticmethod
+    def _saved_display_form_data(slc: Slice) -> dict[str, Any]:
+        """Read saved display controls without model-derived form-data overrides."""
+        try:
+            form_data = json.loads(slc.params or "{}")
+        except (TypeError, ValueError):
+            return {}
+        return form_data if isinstance(form_data, dict) else {}
+
+    @classmethod
+    def _slice_display_references(cls, slc: Slice) -> ChartDisplayReferences:
+        """Collect label references from editor-controlled saved display controls.
+
+        Query context is viewer-writable and cannot expand the label allowlist.
+        Filter, sort, granularity and expression-internal dependencies are not
+        display controls, even when plugins include them in query outputs.
+        Query-mode pruning applies to table, AG Grid and Handlebars; other
+        plugins retain recognized controls even if a legacy or imported chart
+        saved fields that its visualization no longer uses.
+        """
+        form_data = cls._saved_display_form_data(slc)
+        viz_type = slc.viz_type or form_data.get("viz_type")
+        return cls._form_display_references(
+            cls._active_display_form_data(form_data, viz_type), viz_type
+        )
+
     def data_for_slices(  # pylint: disable=too-many-locals  # noqa: C901
         self, slices: list[Slice]
     ) -> dict[str, Any]:
@@ -665,6 +872,7 @@ class BaseDatasource(
         data = cast(dict[str, Any], self.data)
         metric_names = set()
         column_names = set()
+        authored_labels: dict[str, str] = {}
         for slc in slices:
             form_data = slc.form_data
             # pull out all required metrics from the form_data
@@ -708,6 +916,36 @@ class BaseDatasource(
                 ]
                 column_names.update(_columns)
 
+            # Full-access metadata retains hidden controls and query-only
+            # dependencies. Only saved display controls supply authored labels.
+            display_columns, display_metrics = self._form_display_references(
+                form_data,
+                slc.viz_type or self._saved_display_form_data(slc).get("viz_type"),
+            )
+            for references in (display_columns, display_metrics):
+                for reference in references:
+                    if isinstance(reference, dict) and (
+                        label := _authored_display_label(reference)
+                    ):
+                        authored_labels.setdefault(label, label)
+            query_columns, query_metrics = self._query_output_references(
+                slc.query_context
+            )
+            display_columns.extend(query_columns)
+            display_metrics.extend(query_metrics)
+            for references, names in (
+                (display_columns, column_names),
+                (display_metrics, metric_names),
+            ):
+                for reference in references:
+                    if isinstance(reference, str):
+                        names.add(reference)
+            for reference in display_columns:
+                if isinstance(reference, dict) and (
+                    column_name := _physical_display_column(reference)
+                ):
+                    column_names.add(column_name)
+
         filtered_metrics = [
             metric
             for metric in data["metrics"]
@@ -742,7 +980,9 @@ class BaseDatasource(
             }
         )
         verbose_map.update(all_columns)
-        data["verbose_map"] = verbose_map
+        # Dataset labels take precedence when an authored label collides with
+        # a saved field name, matching the dashboard frontend's merge order.
+        data["verbose_map"] = {**authored_labels, **verbose_map}
         data["column_names"] = set(all_columns.values()) | set(self.column_names)
 
         return data
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index 7883c6ab47b..a46a7157237 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -20,7 +20,7 @@ import logging
 import re
 from collections import defaultdict
 from datetime import datetime
-from typing import Any, Dict, List
+from typing import Any, Callable, Dict, List, TYPE_CHECKING
 
 from flask import g
 from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -58,6 +58,10 @@ from superset.utils.core import get_user_id
 from superset.utils.dashboard_filter_scopes_converter import copy_filter_scopes
 from superset.utils.filters import is_excluded_by_extra_access
 
+if TYPE_CHECKING:
+    from superset.connectors.sqla.models import BaseDatasource
+    from superset.semantic_layers.models import SemanticView
+
 logger = logging.getLogger(__name__)
 
 # Custom filterable fields for dashboards
@@ -484,9 +488,17 @@ class DashboardDAO(BaseDAO[Dashboard]):
         ).all()
 
     @staticmethod
-    def get_datasets_for_dashboard(id_or_slug: str) -> list[tuple[Any, dict[str, Any]]]:
+    def get_datasets_for_dashboard(
+        id_or_slug: str,
+        *,
+        can_access_datasource: Callable[[BaseDatasource | SemanticView], bool]
+        | None = None,
+    ) -> list[tuple[Any, dict[str, Any]]]:
+        """Load datasets, sharing the endpoint's datasource authorization check."""
         dashboard = DashboardDAO.get_by_id_or_slug(id_or_slug)
-        return dashboard.datasets_trimmed_for_slices()
+        return dashboard.datasets_trimmed_for_slices(
+            can_access_datasource=can_access_datasource
+        )
 
     @staticmethod
     def get_tabs_for_dashboard(id_or_slug: str) -> dict[str, Any]:
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index bba2857eb92..28c74d170f4 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -111,6 +111,7 @@ from superset.commands.importers.v1.utils import (
     open_import_bundle,
 )
 from superset.commands.purge import SoftDeleteBinding
+from superset.connectors.sqla.models import BaseDatasource
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
 from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
 from superset.dashboards.excel_export.download_link import (
@@ -371,6 +372,8 @@ CUSTOM_TAG_LIST_COLUMNS = BASE_LIST_COLUMNS + [
 # connection, and query construction. The identifying fields the dashboard
 # needs to render its charts are kept. This is a stricter version of the
 # narrowing DashboardDatasetSchema.post_dump already applies to guest users.
+# ``chart_display_labels`` is deliberately absent: it is authorized per member
+# chart when built and carries label strings only, never the dataset's schema.
 DASHBOARD_DATASET_INACCESSIBLE_FIELDS = (
     "metadata_error",
     "sql",
@@ -861,9 +864,17 @@ class DashboardRestApi(
               $ref: '#/components/responses/404'
         """
         try:
-            datasets = DashboardDAO.get_datasets_for_dashboard(id_or_slug)
+            # Reuse the same permission decision for labels and metadata narrowing.
+            can_access_datasource = functools.cache(
+                security_manager.can_access_datasource
+            )
+            datasets = DashboardDAO.get_datasets_for_dashboard(
+                id_or_slug, can_access_datasource=can_access_datasource
+            )
             result = [
-                self._serialize_dashboard_dataset(datasource, payload)
+                self._serialize_dashboard_dataset(
+                    datasource, payload, can_access_datasource=can_access_datasource
+                )
                 for datasource, payload in datasets
             ]
             return self.response(200, result=result)
@@ -871,7 +882,12 @@ class DashboardRestApi(
             raise DatasetValidationError(err) from err
 
     def _serialize_dashboard_dataset(
-        self, datasource: Any, payload: dict[str, Any]
+        self,
+        datasource: BaseDatasource | SemanticView,
+        payload: dict[str, Any],
+        *,
+        can_access_datasource: Callable[[BaseDatasource | SemanticView], bool]
+        | None = None,
     ) -> dict[str, Any]:
         """Dump a member dataset, narrowed when the caller cannot access it."""
         serialized = self.dashboard_dataset_schema.dump(payload)
@@ -879,7 +895,8 @@ class DashboardRestApi(
             # Redux keys dashboard datasets by Slice.form_data["datasource"],
             # whereas SemanticView.uid is the provider's independent identity.
             serialized["uid"] = f"{datasource.id}__{datasource.type}"
-        if not security_manager.can_access_datasource(datasource):
+        access_check = can_access_datasource or security_manager.can_access_datasource
+        if not access_check(datasource):
             for key in DASHBOARD_DATASET_INACCESSIBLE_FIELDS:
                 serialized.pop(key, None)
         return serialized
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 3d829aae41c..409240d2eae 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -378,6 +378,19 @@ class DashboardDatasetSchema(Schema):
     metrics = fields.List(fields.Dict())
     order_by_choices = fields.List(fields.List(fields.Str()))
     verbose_map = fields.Dict(fields.Str(), fields.Str())
+    # Built server-side from accessible member charts only, so it survives both
+    # datasource-access narrowing (which removes ``verbose_map``) and guest narrowing.
+    chart_display_labels = fields.Dict(
+        keys=fields.Str(),
+        values=fields.Dict(keys=fields.Str(), values=fields.Str()),
+        metadata={
+            "description": "Display labels keyed by member chart ID, then by the "
+            "raw column or metric ID that chart uses. Only charts the caller can "
+            "access and labels configured for their saved dimensions and metrics "
+            "are included; omitted for semantic views, when there are none, or "
+            "when the caller has datasource access and already receives verbose_map."
+        },
+    )
     time_grain_sqla = fields.List(fields.List(fields.Str()))
     granularity_sqla = fields.List(fields.List(fields.Str()))
     normalize_columns = fields.Bool()
diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index 7e4e50554f2..459e8d242d2 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -43,7 +43,12 @@ from sqlalchemy.sql.elements import BinaryExpression
 from superset_core.common.models import Dashboard as CoreDashboard
 
 from superset import db, is_feature_enabled, security_manager
-from superset.connectors.sqla.models import BaseDatasource, SqlaTable
+from superset.connectors.sqla.models import (
+    _authored_display_label,
+    _physical_display_column,
+    BaseDatasource,
+    SqlaTable,
+)
 from superset.daos.datasource import Datasource, DatasourceDAO
 from superset.models.helpers import (
     AuditMixinNullable,
@@ -178,6 +183,128 @@ dashboard_slices = Table(
 )
 
 
+def _configured_display_label(value: Any) -> str | None:
+    """Return a configured label, or ``None`` to keep the raw-name fallback."""
+    return value if isinstance(value, str) and value.strip() else None
+
+
+def _authored_adhoc_label(item: dict[str, Any]) -> str | None:
+    """Return an adhoc column/metric label only when its author set it.
+
+    A generated adhoc label can repeat the item's SQL expression or aggregated
+    physical column, so it is never echoed; the client derives that same text
+    from the chart's own form data.
+    """
+    return _authored_display_label(item)
+
+
+def _dataset_display_labels(
+    datasource: SqlaTable,
+) -> tuple[dict[str, str], dict[str, str]]:
+    """Index configured column and metric labels once per dataset payload."""
+    return (
+        {
+            column.column_name: label
+            for column in datasource.columns
+            if (label := _configured_display_label(column.verbose_name))
+        },
+        {
+            metric.metric_name: label
+            for metric in datasource.metrics
+            if (label := _configured_display_label(metric.verbose_name))
+        },
+    )
+
+
+def saved_chart_display_labels(
+    datasource: SqlaTable,
+    chart: Slice,
+    *,
+    configured_labels: tuple[dict[str, str], dict[str, str]] | None = None,
+) -> dict[str, str]:
+    """Map a chart's saved dimension/metric IDs to their configured labels.
+
+    Display references come from recognized saved display controls, with
+    query-mode pruning for table, AG Grid and Handlebars charts. Viewer-writable
+    query context cannot add labels. Raw names resolve against the dataset's
+    stored column and saved-metric ``verbose_name`` attributes. An entry is
+    omitted when no label is configured. Expressions are never parsed: columns
+    appearing only inside an expression, aggregate or filter, and dataset fields
+    absent from the recognized controls are excluded. No external metadata is
+    discovered.
+
+    :param datasource: The chart's SQLAlchemy dataset
+    :param chart: A chart whose datasource is ``datasource``
+    :param configured_labels: Optional dataset indexes shared by this request
+    :returns: Raw field/metric ID to display label, strings only
+    """
+    column_labels, metric_labels = (
+        configured_labels
+        if configured_labels is not None
+        else _dataset_display_labels(datasource)
+    )
+    labels: dict[str, str] = {}
+    # pylint: disable=protected-access
+    columns, metrics = datasource._slice_display_references(chart)
+    for references, stored_labels in (
+        (columns, column_labels),
+        (metrics, metric_labels),
+    ):
+        for reference in references:
+            if isinstance(reference, str):
+                if label := stored_labels.get(reference):
+                    labels.setdefault(reference, label)
+            elif isinstance(reference, dict):
+                if (
+                    stored_labels is column_labels
+                    and (column_name := _physical_display_column(reference))
+                    and (label := column_labels.get(column_name))
+                ):
+                    labels.setdefault(column_name, label)
+                elif label := _authored_adhoc_label(reference):
+                    labels.setdefault(label, label)
+    return labels
+
+
+class _ChartLabelAccess:  # pylint: disable=too-few-public-methods
+    """Decide which member charts of one dashboard may contribute labels.
+
+    Lives for a single ``datasets_trimmed_for_slices`` call, so nothing is
+    cached across requests. Chart authorization runs only when a chart has a
+    configured dataset label or an authored adhoc label to contribute.
+    """
+
+    def __init__(self, dashboard: Dashboard) -> None:
+        self._dashboard = dashboard
+        self._is_guest: bool | None = None
+        self._guest_granted = False
+
+    def allows(self, chart: Slice, *, datasource_access: bool) -> bool:
+        """Whether ``chart``, a member on its own SQLAlchemy dataset, may label.
+
+        :param chart: A member chart of the dashboard
+        :param datasource_access: This request's check for the chart's dataset
+        :returns: Whether the current user may see the chart's labels
+        """
+        if self._is_guest is None:
+            self._is_guest = bool(security_manager.is_guest_user())
+            if self._is_guest:
+                # The dashboard lookup already enforces this; never let a
+                # token for another dashboard read labels via a shared chart.
+                self._guest_granted = security_manager.has_guest_access(self._dashboard)
+            else:
+                # pylint: disable=import-outside-toplevel
+                from superset.daos.dashboard import DashboardDAO
+
+                DashboardDAO.prefetch_chart_access(self._dashboard)
+        return security_manager.can_access_dashboard_chart_labels(
+            self._dashboard,
+            chart,
+            guest_dashboard_access=self._guest_granted if self._is_guest else None,
+            datasource_access=datasource_access,
+        )
+
+
 class Dashboard(CoreDashboard, SoftDeleteMixin, AuditMixinNullable, ImportExportMixin):
     """The dashboard object!"""
 
@@ -417,8 +544,15 @@ class Dashboard(CoreDashboard, SoftDeleteMixin, AuditMixinNullable, ImportExport
 
     def datasets_trimmed_for_slices(
         self,
+        *,
+        can_access_datasource: Callable[[BaseDatasource | SemanticView], bool]
+        | None = None,
     ) -> list[tuple[BaseDatasource | SemanticView, dict[str, Any]]]:
-        """Return trimmed chart metadata, keeping datasource types distinct."""
+        """Return trimmed chart metadata, keeping datasource types distinct.
+
+        The endpoint supplies its request-local datasource check so label assembly
+        and metadata serialization share the same authorization decision.
+        """
         # Key by (datasource_type, datasource_id): SqlaTable and SemanticView
         # have independent auto-increment id spaces, so grouping by the bare
         # datasource_id would merge unrelated datasources with colliding ids.
@@ -431,15 +565,19 @@ class Dashboard(CoreDashboard, SoftDeleteMixin, AuditMixinNullable, ImportExport
             slices_by_datasource[(slc.datasource_type, slc.datasource_id)].add(slc)
 
         result: list[tuple[BaseDatasource | SemanticView, dict[str, Any]]] = []
+        label_access = _ChartLabelAccess(self)
+        datasource_access = (
+            can_access_datasource or security_manager.can_access_datasource
+        )
 
         for _, slices in slices_by_datasource.items():
             # Resolve once per typed datasource, retaining eager-loaded tables.
             datasource: Datasource | None = next(iter(slices)).resolved_datasource
 
             if isinstance(datasource, (BaseDatasource, SemanticView)):
-                if isinstance(
-                    datasource, SemanticView
-                ) and not security_manager.can_access_datasource(datasource):
+                if isinstance(datasource, SemanticView) and not datasource_access(
+                    datasource
+                ):
                     # Keep the dashboard lookup identity without instantiating
                     # a provider or discovering metadata with stored credentials.
                     result.append(
@@ -488,10 +626,85 @@ class Dashboard(CoreDashboard, SoftDeleteMixin, AuditMixinNullable, ImportExport
                         type(ex).__name__,
                     )
                     continue
-                result.append((datasource, payload))
+                result.append(
+                    (
+                        datasource,
+                        self._with_chart_display_labels(
+                            datasource,
+                            slices,
+                            payload,
+                            label_access,
+                            can_access_datasource=datasource_access,
+                        ),
+                    )
+                )
 
         return result
 
+    def _with_chart_display_labels(
+        self,
+        datasource: BaseDatasource | SemanticView,
+        slices: set[Slice],
+        payload: dict[str, Any],
+        access: _ChartLabelAccess,
+        *,
+        can_access_datasource: Callable[[BaseDatasource | SemanticView], bool]
+        | None = None,
+    ) -> dict[str, Any]:
+        """Add display labels of this dashboard's accessible member charts.
+
+        Only SQLAlchemy datasets get ``chart_display_labels``, and only this
+        dashboard's own member charts on ``datasource`` that the caller may
+        access contribute, each limited to its saved dimensions and metrics.
+        Nothing is taken from the request. Callers with datasource access keep
+        the existing verbose map and skip label assembly and chart authorization.
+        Callers without that access see authorized chart labels only. The key is
+        omitted when no label is authorized.
+
+        :param datasource: The datasource shared by ``slices``
+        :param slices: Member charts of this dashboard on ``datasource``
+        :param payload: The trimmed datasource payload, updated in place
+        :param access: This request's chart authorization for labels
+        :param can_access_datasource: This request's shared datasource check
+        :returns: ``payload``
+        """
+        if not isinstance(datasource, SqlaTable):
+            return payload
+        datasource_access = (
+            can_access_datasource or security_manager.can_access_datasource
+        )
+        if datasource_access(datasource):
+            return payload
+
+        configured_labels = _dataset_display_labels(datasource)
+        verbose_map = payload.get("verbose_map", {})
+        labels: dict[str, dict[str, str]] = {}
+        for chart in sorted(slices, key=lambda slc: slc.id or 0):
+            if (
+                chart.id is None
+                or chart.datasource_type != utils.DatasourceType.TABLE
+                or chart.datasource_id != datasource.id
+            ):
+                continue
+            # Building the labels reads stored metadata only, so the access
+            # check runs just for charts that would contribute an entry.
+            chart_labels = saved_chart_display_labels(
+                datasource, chart, configured_labels=configured_labels
+            )
+            # A raw ID can collide with another chart's saved field or authored
+            # label. Omit conflicting labels rather than expose a different
+            # field's display name or disagree with the full-access payload.
+            chart_labels = {
+                name: label
+                for name, label in chart_labels.items()
+                if verbose_map.get(name) == label
+            }
+            if chart_labels and access.allows(chart, datasource_access=False):
+                labels[str(chart.id)] = chart_labels
+        if labels:
+            payload["chart_display_labels"] = labels
+        return payload
+
     @property
     def params(self) -> str:
         return self.json_metadata
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 2f537cd51ad..1e30fc85de0 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -2756,6 +2756,67 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
 
         return True
 
+    def can_access_dashboard_chart_labels(
+        self,
+        dashboard: "Dashboard",
+        chart: "Slice",
+        *,
+        guest_dashboard_access: bool | None = None,
+        datasource_access: bool | None = None,
+    ) -> bool:
+        """Authorize a member chart's configured display labels.
+
+        Guests must hold a grant for this exact dashboard and satisfy their
+        token's dataset allowlist. With the built-in chart checks these imply
+        the embedded-chart grant, avoiding per-chart metadata queries. Existing
+        custom chart-access overrides still run and may restrict that grant.
+        For non-guests without datasource access, the built-in chart checks
+        need only the chart's prefetched editors and viewers. Custom checks and
+        configured access bypasses retain the full chart authorization path.
+
+        :param dashboard: The dashboard whose member chart supplies the labels
+        :param chart: A member chart on its matching SQLAlchemy dataset
+        :param guest_dashboard_access: Request-local result of has_guest_access;
+            callers may supply it to reuse the same dashboard decision
+        :param datasource_access: Request-local result of can_access_datasource
+            for this chart's dataset; False avoids repeating a failed check
+        :returns: Whether the caller may receive the chart's display labels
+        """
+        if (
+            chart.datasource_type != DatasourceType.TABLE
+            or chart not in dashboard.slices
+        ):
+            return False
+
+        stock_chart_access = (
+            getattr(self.can_access_chart, "__func__", None) is STOCK_CAN_ACCESS_CHART
+            and getattr(self.raise_for_access, "__func__", None)
+            is STOCK_RAISE_FOR_ACCESS
+        )
+        if not self.is_guest_user():
+            if (
+                datasource_access is False
+                and stock_chart_access
+                and not current_app.config.get("EXTRA_RAISE_FOR_ACCESS_BYPASS")
+            ):
+                return (
+                    self.is_admin()
+                    or self.is_editor(chart)
+                    or (bool(chart.viewers) and self.is_viewer(chart))
+                )
+            return self.can_access_chart(chart)
+
+        if guest_dashboard_access is None:
+            guest_dashboard_access = self.has_guest_access(dashboard)
+        if not guest_dashboard_access or not self._guest_token_allows_dataset(
+            chart.datasource_id
+        ):
+            return False
+
+        if stock_chart_access:
+            return True
+        return self.can_access_chart(chart)
+
     def get_dashboard_access_error_object(  # pylint: disable=invalid-name
         self,
         dashboard: "Dashboard",  # pylint: disable=unused-argument
@@ -6855,5 +6916,6 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
                     security_menu.childs.remove(item)
 
 
-# Keep the original function identity before operator configuration can replace it.
+# Keep original function identities before operator configuration can replace them.
 STOCK_RAISE_FOR_ACCESS: Callable[..., None] = SupersetSecurityManager.raise_for_access
+STOCK_CAN_ACCESS_CHART: Callable[..., bool] = SupersetSecurityManager.can_access_chart
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index 9842fa32f57..c14b49c018f 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -18,7 +18,7 @@
 """Unit tests for Superset"""
 
 import uuid
-from collections.abc import Iterator
+from collections.abc import Callable, Iterator
 from contextlib import contextmanager
 from datetime import datetime, timedelta
 from io import BytesIO
@@ -42,7 +42,7 @@ import yaml
 
 from flask import g
 from freezegun import freeze_time
-from sqlalchemy import and_
+from sqlalchemy import and_, event
 from sqlalchemy.engine.reflection import Inspector
 from sqlalchemy.orm.exc import StaleDataError
 from superset import db, security_manager  # noqa: F401
@@ -96,6 +96,9 @@ from tests.integration_tests.fixtures.importexport import (
     dashboard_metadata_config,
     dataset_config,
 )
+from tests.integration_tests.fixtures.public_role import (
+    public_role_like_gamma_scoped,  # noqa: F401
+)
 from tests.integration_tests.fixtures.tags import (
     create_custom_tags,  # noqa: F401
     get_filter_params,
@@ -401,6 +404,616 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
             # The identifying fields the dashboard needs to render are kept.
             assert {"id", "uid", "table_name", "type"} <= set(dataset)
 
+    WORLD_HEALTH_LABELS = {"country_name": "Country", "sum__SP_POP_TOTL": "Population"}
+
+    @contextmanager
+    def _world_health_display_labels(self) -> Iterator[dict[str, int]]:
+        """Label one saved dimension and one saved metric of world_health.
+
+        Yields the shared dataset id under ``table_id`` and each chart id by
+        name. Every world_health chart is built on that one dataset.
+        """
+        from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+
+        dashboard = Dashboard.get("world_health")
+        ids = {slc.slice_name: slc.id for slc in dashboard.slices}
+        table = db.session.get(SqlaTable, dashboard.slices[0].datasource_id)
+        assert {slc.datasource_id for slc in dashboard.slices} == {table.id}
+        column = next(col for col in table.columns if col.column_name == "country_name")
+        metric = next(
+            (m for m in table.metrics if m.metric_name == "sum__SP_POP_TOTL"), None
+        )
+        added_metric = metric is None
+        if metric is None:
+            metric = SqlMetric(
+                metric_name="sum__SP_POP_TOTL", expression="SUM(SP_POP_TOTL)"
+            )
+            table.metrics.append(metric)
+        previous = (column.verbose_name, metric.verbose_name)
+        column.verbose_name = self.WORLD_HEALTH_LABELS["country_name"]
+        metric.verbose_name = self.WORLD_HEALTH_LABELS["sum__SP_POP_TOTL"]
+        db.session.commit()
+        column_id, metric_id = column.id, metric.id
+        try:
+            yield {"table_id": table.id, **ids}
+        finally:
+            db.session.get(TableColumn, column_id).verbose_name = previous[0]
+            metric = db.session.get(SqlMetric, metric_id)
+            if added_metric:
+                db.session.delete(metric)
+            else:
+                metric.verbose_name = previous[1]
+            db.session.commit()
+
+    def _world_health_dataset(self, response: Any, table_id: int) -> dict[str, Any]:
+        assert response.status_code == 200
+        data = json.loads(response.data.decode("utf-8"))
+        [dataset] = [item for item in data["result"] if item["id"] == table_id]
+        return dataset
+
+    def _assert_world_health_labels(
+        self, labels: dict[str, Any], ids: dict[str, int]
+    ) -> None:
+        """Check configured stored names and the example's authored adhoc label."""
+        for chart_name in ("Most Populated Countries", "Growth Rate"):
+            assert labels[str(ids[chart_name])] == self.WORLD_HEALTH_LABELS
+        for chart_id, chart_labels in labels.items():
+            assert chart_id in {str(chart_id) for chart_id in ids.values()}
+            allowed = dict(self.WORLD_HEALTH_LABELS)
+            if chart_id in {str(ids["% Rural"]), str(ids["Rural Breakdown"])}:
+                allowed["Rural Population"] = "Rural Population"
+            assert chart_labels.items() <= allowed.items()
+
+    @contextmanager
+    def _without_world_health_authored_labels(self) -> Iterator[None]:
+        """Temporarily remove the example's custom labels for the empty baseline."""
+        from superset.models.slice import Slice
+
+        def disable_custom_labels(value: Any) -> Any:
+            """Retain saved expressions while marking their labels as generated."""
+            if isinstance(value, dict):
+                return {
+                    key: False
+                    if key == "hasCustomLabel"
+                    else disable_custom_labels(item)
+                    for key, item in value.items()
+                }
+            if isinstance(value, list):
+                return [disable_custom_labels(item) for item in value]
+            return value
+
+        dashboard = Dashboard.get("world_health")
+        previous = {
+            chart.id: (chart.params, chart.query_context) for chart in dashboard.slices
+        }
+        for chart in dashboard.slices:
+            chart.params = json.dumps(disable_custom_labels(json.loads(chart.params)))
+            if chart.query_context:
+                chart.query_context = json.dumps(
+                    disable_custom_labels(json.loads(chart.query_context))
+                )
+        db.session.commit()
+        try:
+            yield
+        finally:
+            for chart_id, values in previous.items():
+                chart = db.session.get(Slice, chart_id)
+                chart.params, chart.query_context = values
+            db.session.commit()
+
+    @parameterized.expand(
+        [
+            ("table", {"all_columns": ["country_name"]}, {"country_name": "Country"}),
+            (
+                "pivot_table_v2",
+                {"groupbyRows": ["country_name"], "metrics": ["sum__SP_POP_TOTL"]},
+                WORLD_HEALTH_LABELS,
+            ),
+            (
+                "echarts_timeseries_line",
+                {"x_axis": "country_name", "metrics": ["sum__SP_POP_TOTL"]},
+                WORLD_HEALTH_LABELS,
+            ),
+            (
+                "mixed_timeseries",
+                {"groupby_b": ["country_name"], "metrics_b": ["sum__SP_POP_TOTL"]},
+                WORLD_HEALTH_LABELS,
+            ),
+            (
+                "big_number_total",
+                {"metric": "sum__SP_POP_TOTL"},
+                {"sum__SP_POP_TOTL": "Population"},
+            ),
+            (
+                "pie",
+                {"groupby": ["country_name"], "metric": "sum__SP_POP_TOTL"},
+                WORLD_HEALTH_LABELS,
+            ),
+            (
+                "bubble",
+                {"entity": "country_name", "x": "sum__SP_POP_TOTL"},
+                WORLD_HEALTH_LABELS,
+            ),
+        ]
+    )
+    @pytest.mark.usefixtures(
+        "load_world_bank_dashboard_with_slices", "public_role_like_gamma_scoped"
+    )
+    @with_feature_flags(EMBEDDED_SUPERSET=True)
+    def test_get_dashboard_datasets_chart_display_labels_visualizations(
+        self, viz_type: str, controls: dict[str, Any], expected: dict[str, str]
+    ) -> None:
+        """A real embedded request labels each visualization's persisted controls."""
+        from superset.models.slice import Slice
+
+        with self._world_health_display_labels() as ids:
+            chart_id = ids["Most Populated Countries"]
+            chart = db.session.get(Slice, chart_id)
+            previous = chart.params, chart.query_context, chart.viz_type
+            chart.params = json.dumps({"viz_type": viz_type, **controls})
+            chart.query_context = None
+            chart.viz_type = viz_type
+            db.session.commit()
+            try:
+                response = self._get_world_health_datasets_as_guest()
+                dataset = self._world_health_dataset(response, ids["table_id"])
+                assert dataset["chart_display_labels"][str(chart_id)] == expected
+                assert "verbose_map" not in dataset
+                assert "columns" not in dataset
+                assert "metrics" not in dataset
+            finally:
+                chart = db.session.get(Slice, chart_id)
+                chart.params, chart.query_context, chart.viz_type = previous
+                db.session.commit()
+
+    @parameterized.expand([ADMIN_USERNAME, ALPHA_USERNAME])
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    def test_get_dashboard_datasets_chart_display_labels_full_access(
+        self, username: str
+    ) -> None:
+        """Datasource access supplies verbose_map without a redundant chart map."""
+        with self._world_health_display_labels() as ids:
+            dashboard = Dashboard.get("world_health")
+            dashboard.published = True
+            db.session.commit()
+            self.login(username)
+            response = self.get_assert_metric(
+                "api/v1/dashboard/world_health/datasets", "get_datasets"
+            )
+            dataset = self._world_health_dataset(response, ids["table_id"])
+
+        assert "chart_display_labels" not in dataset
+        assert self.WORLD_HEALTH_LABELS.items() <= dataset["verbose_map"].items()
+
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    @patch("superset.dashboards.api.security_manager.can_access_datasource")
+    def test_get_dashboard_datasets_chart_display_labels_without_datasource_access(
+        self, can_access_datasource_mock: MagicMock
+    ) -> None:
+        """Chart labels survive the narrowing that strips dataset metadata."""
+        can_access_datasource_mock.return_value = False
+        with self._world_health_display_labels() as ids:
+            self.login(ADMIN_USERNAME)
+            response = self.get_assert_metric(
+                "api/v1/dashboard/world_health/datasets", "get_datasets"
+            )
+            dataset = self._world_health_dataset(response, ids["table_id"])
+
+        self._assert_world_health_labels(dataset["chart_display_labels"], ids)
+        for excluded_key in ("verbose_map", "columns", "column_names", "metrics"):
+            assert excluded_key not in dataset
+
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    def test_get_dashboard_datasets_chart_display_labels_follow_chart_access(
+        self,
+    ) -> None:
+        """Only member charts that pass the chart access check contribute."""
+        with self._world_health_display_labels() as ids:
+            allowed_id = ids["Most Populated Countries"]
+            self.login(ADMIN_USERNAME)
+            with (
+                patch(
+                    "superset.models.dashboard.security_manager.can_access_chart",
+                    side_effect=lambda chart: chart.id == allowed_id,
+                ),
+                patch(
+                    "superset.models.dashboard.security_manager.can_access_datasource",
+                    return_value=False,
+                ),
+            ):
+                response = self.get_assert_metric(
+                    "api/v1/dashboard/world_health/datasets", "get_datasets"
+                )
+            dataset = self._world_health_dataset(response, ids["table_id"])
+
+        assert dataset["chart_display_labels"] == {
+            str(allowed_id): self.WORLD_HEALTH_LABELS
+        }
+
+    def _world_health_guest_request(
+        self,
+        datasets: list[int] | None = None,
+        dashboard_ref: str | None = None,
+    ) -> Callable[[], Any]:
+        """Return a world_health datasets request with a real guest token."""
+        dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
+        embedded = EmbeddedDashboardDAO.upsert(dashboard, ["superset.example"])
+        db.session.commit()
+        token = security_manager.create_guest_access_token(
+            {"username": "labels_guest"},
+            [
+                {
+                    "type": GuestTokenResourceType.DASHBOARD,
+                    "id": dashboard_ref or str(embedded.uuid),
+                }
+            ],
+            [],
+            datasets=datasets,
+        )
+        uri = f"api/v1/dashboard/{dashboard.id}/datasets"
+        headers = {
+            current_app.config["GUEST_TOKEN_HEADER_NAME"]: token.decode("utf-8")
+            if isinstance(token, bytes)
+            else token
+        }
+
+        def request() -> Any:
+            with self.client as client:
+                response = client.get(uri, headers=headers)
+                assert isinstance(g.user, GuestUser)
+            return response
+
+        return request
+
+    def _get_world_health_datasets_as_guest(
+        self,
+        datasets: list[int] | None = None,
+        dashboard_ref: str | None = None,
+    ) -> Any:
+        """Request world_health datasets with a real embedded guest token."""
+        return self._world_health_guest_request(datasets, dashboard_ref)()
+
+    @staticmethod
+    @contextmanager
+    def _count_metadata_queries() -> Iterator[list[str]]:
+        """Record every statement sent to the metadata database."""
+        statements: list[str] = []
+
+        def record(*args: Any) -> None:
+            statements.append(args[2])
+
+        event.listen(db.engine, "before_cursor_execute", record)
+        try:
+            yield statements
+        finally:
+            event.remove(db.engine, "before_cursor_execute", record)
+
+    def _datasets_query_counts(
+        self, request: Callable[[], Any]
+    ) -> tuple[int, int, dict[str, Any]]:
+        """Count queries without and with chart display labels.
+
+        Returns the count with the label step bypassed, the count with it, and
+        the labelled response body. A first request warms per-process caches.
+        """
+        assert request().status_code == 200
+        with patch.object(
+            Dashboard,
+            "_with_chart_display_labels",
+            lambda self, *args, **kwargs: args[2],
+        ):
+            with self._count_metadata_queries() as baseline:
+                assert request().status_code == 200
+        with self._count_metadata_queries() as statements:
+            response = request()
+        assert response.status_code == 200
+        return len(baseline), len(statements), json.loads(response.data)
+
+    @staticmethod
+    def _labelled_chart_count(data: dict[str, Any]) -> int:
+        return sum(
+            len(dataset.get("chart_display_labels", {})) for dataset in data["result"]
+        )
+
+    @pytest.mark.usefixtures(
+        "load_world_bank_dashboard_with_slices", "public_role_like_gamma_scoped"
+    )
+    @with_feature_flags(EMBEDDED_SUPERSET=True)
+    def test_get_dashboard_datasets_chart_display_labels_guest_query_count(
+        self,
+    ) -> None:
+        """Labels add no metadata queries for an embedded guest, however many
+        charts carry them: the guest grant is decided once, not per chart."""
+        with self._world_health_display_labels():
+            request = self._world_health_guest_request()
+            baseline, labelled, data = self._datasets_query_counts(request)
+
+        assert self._labelled_chart_count(data) >= 2
+        assert labelled == baseline
+
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    def test_get_dashboard_datasets_chart_display_labels_query_count(self) -> None:
+        """Datasource access avoids label permission queries even with labels."""
+        dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
+        dashboard.published = True
+        db.session.commit()
+        self.login(ALPHA_USERNAME)
+
+        def request() -> Any:
+            return self.client.get("api/v1/dashboard/world_health/datasets")
+
+        with (
+            self._without_world_health_authored_labels(),
+            patch.object(
+                DashboardDAO,
+                "prefetch_chart_access",
+                wraps=DashboardDAO.prefetch_chart_access,
+            ) as prefetch,
+        ):
+            baseline, unlabelled, data = self._datasets_query_counts(request)
+        assert self._labelled_chart_count(data) == 0
+        assert unlabelled == baseline
+        prefetch.assert_not_called()
+
+        with (
+            self._world_health_display_labels(),
+            patch.object(
+                DashboardDAO,
+                "prefetch_chart_access",
+                wraps=DashboardDAO.prefetch_chart_access,
+            ) as prefetch,
+        ):
+            baseline, labelled, data = self._datasets_query_counts(request)
+        assert self._labelled_chart_count(data) == 0
+        assert labelled == baseline
+        prefetch.assert_not_called()
+        assert any(
+            self.WORLD_HEALTH_LABELS.items() <= dataset["verbose_map"].items()
+            for dataset in data["result"]
+        )
+
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    def test_get_dashboard_datasets_chart_display_labels_dashboard_viewer(self) -> None:
+        """A dashboard viewer without dataset access gets labels only for the
+        member charts it may view. A chart restricted to another viewer and
+        charts that rely on dataset access contribute nothing, although they
+        share the same dataset. Authorization adds one relationship prefetch for
+        all candidate charts, including denied charts.
+        """
+        from superset.subjects.utils import get_or_create_user_subject
+
+        gamma_id = security_manager.find_user(GAMMA_USERNAME).id
+        admin_id = security_manager.find_user(ADMIN_USERNAME).id
+        with self._world_health_display_labels() as ids:
+            allowed_id = ids["Most Populated Countries"]
+            restricted_id = ids["Growth Rate"]
+            dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
+            gamma_subject = get_or_create_user_subject(gamma_id)
+            admin_subject = get_or_create_user_subject(admin_id)
+            dashboard.published = True
+            dashboard.viewers = [gamma_subject]
+            db.session.get(Slice, allowed_id).viewers = [gamma_subject]
+            db.session.get(Slice, restricted_id).viewers = [admin_subject]
+            db.session.commit()
+            try:
+                self.login(GAMMA_USERNAME)
+                with (
+                    patch.object(
+                        DashboardDAO,
+                        "prefetch_chart_access",
+                        wraps=DashboardDAO.prefetch_chart_access,
+                    ) as prefetch,
+                    patch.object(
+                        security_manager,
+                        "can_access_dashboard_chart_labels",
+                        wraps=security_manager.can_access_dashboard_chart_labels,
+                    ) as chart_access,
+                    patch.object(
+                        security_manager,
+                        "can_access_datasource",
+                        wraps=security_manager.can_access_datasource,
+                    ) as datasource_access,
+                ):
+
+                    def request() -> Any:
+                        """Keep counters for the final measured request only."""
+                        prefetch.reset_mock()
+                        chart_access.reset_mock()
+                        datasource_access.reset_mock()
+                        return self.get_assert_metric(
+                            "api/v1/dashboard/world_health/datasets", "get_datasets"
+                        )
+
+                    baseline, labelled, data = self._datasets_query_counts(request)
+                    prefetch.assert_called_once()
+                    datasource_access.assert_called_once()
+                    checked_ids = {
+                        call.args[1].id for call in chart_access.call_args_list
+                    }
+                    assert {allowed_id, restricted_id} <= checked_ids
+                    assert len(checked_ids) >= 9
+                    # The chart rows plus their editors and viewers cost three
+                    # queries total; there is no per-candidate permission query.
+                    assert labelled <= baseline + 3
+                [dataset] = [
+                    item for item in data["result"] if item["id"] == ids["table_id"]
+                ]
+            finally:
+                for chart_id in (allowed_id, restricted_id):
+                    db.session.get(Slice, chart_id).viewers = []
+                db.session.query(Dashboard).filter_by(
+                    slug="world_health"
+                ).one().viewers = []
+                db.session.commit()
+
+        assert dataset["chart_display_labels"] == {
+            str(allowed_id): self.WORLD_HEALTH_LABELS
+        }
+        for excluded_key in ("verbose_map", "columns", "column_names", "metrics"):
+            assert excluded_key not in dataset
+
+    @pytest.mark.usefixtures(
+        "load_world_bank_dashboard_with_slices", "public_role_like_gamma_scoped"
+    )
+    @with_feature_flags(EMBEDDED_SUPERSET=True)
+    def test_get_dashboard_datasets_labels_ignore_viewer_query_context_update(
+        self,
+    ) -> None:
+        """A viewer's permitted context refresh cannot expand display labels."""
+        from superset.subjects.utils import get_or_create_user_subject
+
+        gamma = security_manager.find_user(GAMMA_USERNAME)
+        gamma_subject = get_or_create_user_subject(gamma.id)
+        with self._world_health_display_labels() as ids:
+            chart_id = ids["Most Populated Countries"]
+            chart = db.session.get(Slice, chart_id)
+            dashboard = Dashboard.get("world_health")
+            previous_chart = (
+                chart.params,
+                chart.query_context,
+                chart.viz_type,
+                list(chart.viewers),
+            )
+            previous_dashboard = dashboard.published, list(dashboard.viewers)
+            params = json.dumps(
+                {
+                    "viz_type": "big_number_total",
+                    "metric": "sum__SP_POP_TOTL",
+                    "datasource": f"{ids['table_id']}__table",
+                }
+            )
+            chart.params = params
+            chart.query_context = None
+            chart.viz_type = "big_number_total"
+            chart.viewers = [gamma_subject]
+            dashboard.published = True
+            dashboard.viewers = [gamma_subject]
+            db.session.commit()
+            query_context = json.dumps(
+                {
+                    "datasource": {"id": ids["table_id"], "type": "table"},
+                    "queries": [
+                        {"columns": ["country_name"], "metrics": ["sum__SP_POP_TOTL"]}
+                    ],
+                }
+            )
+            try:
+                with override_user(gamma):
+                    assert not security_manager.is_editor(chart)
+                    assert not security_manager.can_access_datasource(chart.datasource)
+                self.login(GAMMA_USERNAME)
+                update = self.client.put(
+                    f"api/v1/chart/{chart_id}",
+                    json={
+                        "query_context": query_context,
+                        "query_context_generation": True,
+                    },
+                )
+                assert update.status_code == 200
+                chart = db.session.get(Slice, chart_id)
+                assert chart.query_context == query_context
+                assert chart.params == params
+
+                response = self.client.get("api/v1/dashboard/world_health/datasets")
+                dataset = self._world_health_dataset(response, ids["table_id"])
+                assert dataset["chart_display_labels"][str(chart_id)] == {
+                    "sum__SP_POP_TOTL": "Population"
+                }
+                for field in ("verbose_map", "columns", "column_names", "metrics"):
+                    assert field not in dataset
+
+                self.logout()
+                # Flask-Login caches the user on the test's shared app context.
+                with current_app.app_context():
+                    guest_response = self._get_world_health_datasets_as_guest()
+                guest_dataset = self._world_health_dataset(
+                    guest_response, ids["table_id"]
+                )
+                assert guest_dataset["chart_display_labels"][str(chart_id)] == {
+                    "sum__SP_POP_TOTL": "Population"
+                }
+            finally:
+                chart = db.session.get(Slice, chart_id)
+                (
+                    chart.params,
+                    chart.query_context,
+                    chart.viz_type,
+                    chart.viewers,
+                ) = previous_chart
+                dashboard = Dashboard.get("world_health")
+                dashboard.published, dashboard.viewers = previous_dashboard
+                db.session.commit()
+
+    @pytest.mark.usefixtures(
+        "load_world_bank_dashboard_with_slices", "public_role_like_gamma_scoped"
+    )
+    @with_feature_flags(EMBEDDED_SUPERSET=True)
+    def test_get_dashboard_datasets_chart_display_labels_as_guest(self) -> None:
+        """A real embedded guest gets the authorized labels and every redaction."""
+        with self._world_health_display_labels() as ids:
+            response = self._get_world_health_datasets_as_guest()
+            dataset = self._world_health_dataset(response, ids["table_id"])
+
+        self._assert_world_health_labels(dataset["chart_display_labels"], ids)
+        for excluded_key in (
+            "sql",
+            "select_star",
+            "fetch_values_predicate",
+            "template_params",
+            "params",
+            "perm",
+            "edit_url",
+            "database",
+            "parent",
+            "editors",
+            "columns",
+            "column_names",
+            "column_types",
+            "metrics",
+            "verbose_map",
+            "order_by_choices",
+            "main_dttm_col",
+            "granularity_sqla",
+            "time_grain_sqla",
+        ):
+            assert excluded_key not in dataset
+
+    @pytest.mark.usefixtures(
+        "load_world_bank_dashboard_with_slices", "public_role_like_gamma_scoped"
+    )
+    @with_feature_flags(EMBEDDED_SUPERSET=True)
+    def test_get_dashboard_datasets_chart_display_labels_guest_dataset_allowlist(
+        self,
+    ) -> None:
+        """A guest whose dataset allowlist excludes the charts' dataset gets no
+        labels for them, while the dataset entry itself stays narrowed."""
+        with self._world_health_display_labels() as ids:
+            response = self._get_world_health_datasets_as_guest(
+                datasets=[ids["table_id"] + 100_000]
+            )
+            dataset = self._world_health_dataset(response, ids["table_id"])
+
+        assert "chart_display_labels" not in dataset
+        assert "verbose_map" not in dataset
+        assert "Population" not in response.data.decode("utf-8")
+
+    @pytest.mark.usefixtures(
+        "load_world_bank_dashboard_with_slices", "public_role_like_gamma_scoped"
+    )
+    @with_feature_flags(EMBEDDED_SUPERSET=True)
+    def test_get_dashboard_datasets_chart_display_labels_guest_wrong_dashboard(
+        self,
+    ) -> None:
+        """A guest token for another dashboard is refused before any payload."""
+        with self._world_health_display_labels():
+            response = self._get_world_health_datasets_as_guest(
+                dashboard_ref=str(uuid.uuid4())
+            )
+
+        # The guest dashboard filter hides it, as for any dashboard not granted.
+        assert response.status_code == 404
+        assert "Population" not in response.data.decode("utf-8")
+
     @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
     @patch("superset.utils.log.logger")
     def test_get_dashboard_datasets_not_found(self, logger_mock):
diff --git a/tests/integration_tests/fixtures/public_role.py b/tests/integration_tests/fixtures/public_role.py
index 6e851e8df2f..fe8eacbba56 100644
--- a/tests/integration_tests/fixtures/public_role.py
+++ b/tests/integration_tests/fixtures/public_role.py
@@ -14,15 +14,42 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from collections.abc import Iterator
+from contextlib import contextmanager
+
 import pytest
 from flask.ctx import AppContext
+from flask_appbuilder.security.sqla.models import PermissionView
 
 from superset.extensions import db, security_manager
 from tests.integration_tests.test_app import app
 
 
+@contextmanager
+def _public_role_like(role_name: str) -> Iterator[None]:
+    """Temporarily sync Public to a role and restore its exact prior state."""
+    original_value = app.config["PUBLIC_ROLE_LIKE"]
+    original_permission_ids = [
+        permission.id for permission in security_manager.get_public_role().permissions
+    ]
+    try:
+        app.config["PUBLIC_ROLE_LIKE"] = role_name
+        security_manager.sync_role_definitions()
+        yield
+    finally:
+        app.config["PUBLIC_ROLE_LIKE"] = original_value
+        # Tests and role syncing can commit or replace the session's ORM objects.
+        security_manager.get_public_role().permissions = (
+            db.session.query(PermissionView)
+            .filter(PermissionView.id.in_(original_permission_ids))
+            .all()
+        )
+        db.session.commit()
+
+
 @pytest.fixture
-def public_role_like_gamma(app_context: AppContext):
+def public_role_like_gamma(app_context: AppContext) -> Iterator[None]:
+    """Give Public Gamma permissions, then clear the shared test role."""
     app.config["PUBLIC_ROLE_LIKE"] = "Gamma"
     security_manager.sync_role_definitions()
 
@@ -33,7 +60,15 @@ def public_role_like_gamma(app_context: AppContext):
 
 
 @pytest.fixture
-def public_role_like_test_role(app_context: AppContext):
+def public_role_like_gamma_scoped(app_context: AppContext) -> Iterator[None]:
+    """Grant Gamma permissions without changing Public's surrounding state."""
+    with _public_role_like("Gamma"):
+        yield
+
+
+@pytest.fixture
+def public_role_like_test_role(app_context: AppContext) -> Iterator[None]:
+    """Give Public TestRole permissions, then clear the shared test role."""
     app.config["PUBLIC_ROLE_LIKE"] = "TestRole"
     security_manager.sync_role_definitions()
 
@@ -44,18 +79,14 @@ def public_role_like_test_role(app_context: AppContext):
 
 
 @pytest.fixture
-def public_role_builtin(app_context: AppContext):
-    """
-    Fixture that uses the built-in Public role with minimal read-only permissions.
-    This sets PUBLIC_ROLE_LIKE to "Public" to use the new sensible defaults.
-    """
+def public_role_builtin(app_context: AppContext) -> Iterator[None]:
+    """Give Public its built-in permissions, then clear the shared test role."""
     original_value = app.config.get("PUBLIC_ROLE_LIKE")
     app.config["PUBLIC_ROLE_LIKE"] = "Public"
     security_manager.sync_role_definitions()
 
     yield
 
-    # Restore original config and clean up
     app.config["PUBLIC_ROLE_LIKE"] = original_value
     security_manager.get_public_role().permissions = []
     db.session.commit()
diff --git a/tests/integration_tests/security/public_role_tests.py b/tests/integration_tests/security/public_role_tests.py
new file mode 100644
index 00000000000..892f61018c0
--- /dev/null
+++ b/tests/integration_tests/security/public_role_tests.py
@@ -0,0 +1,93 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from contextlib import nullcontext
+from uuid import uuid4
+
+import pytest
+from flask.ctx import AppContext
+from flask_appbuilder.security.sqla.models import PermissionView
+
+from superset.extensions import db, security_manager
+from tests.integration_tests.fixtures.public_role import _public_role_like
+from tests.integration_tests.test_app import app
+
+
+@pytest.mark.parametrize("role_name", ["Gamma", "TestRole", "Public"])
+@pytest.mark.parametrize("test_raises", [False, True])
+def test_public_role_restored_after_commits(
+    app_context: AppContext, role_name: str, test_raises: bool
+) -> None:
+    """Restore custom Public grants after session replacement and test failures."""
+    original_value = app.config["PUBLIC_ROLE_LIKE"]
+    original_permission_ids = {
+        permission.id for permission in security_manager.get_public_role().permissions
+    }
+    suffix = uuid4().hex
+    permission_pairs = [
+        (f"can_read_{suffix}", f"PublicRoleFixtureRead_{suffix}"),
+        (f"can_write_{suffix}", f"PublicRoleFixtureWrite_{suffix}"),
+    ]
+    try:
+        # Use a custom set that cannot be restored by re-syncing a built-in role.
+        app.config["PUBLIC_ROLE_LIKE"] = None
+        permissions = []
+        for permission_name, view_name in permission_pairs:
+            permission = security_manager.add_permission_view_menu(
+                permission_name, view_name
+            )
+            assert permission is not None
+            permissions.append(permission)
+        expected_permission_ids = {permission.id for permission in permissions}
+        security_manager.get_public_role().permissions = permissions
+        db.session.commit()
+
+        with (
+            pytest.raises(RuntimeError, match="Test failure")
+            if test_raises
+            else nullcontext()
+        ):
+            with _public_role_like(role_name):
+                assert app.config["PUBLIC_ROLE_LIKE"] == role_name
+                security_manager.get_public_role().permissions = []
+                db.session.commit()
+                db.session.remove()
+                if test_raises:
+                    raise RuntimeError("Test failure")
+
+        assert app.config["PUBLIC_ROLE_LIKE"] is None
+        assert {
+            permission.id
+            for permission in security_manager.get_public_role().permissions
+        } == expected_permission_ids
+    finally:
+        app.config["PUBLIC_ROLE_LIKE"] = original_value
+        security_manager.get_public_role().permissions = (
+            db.session.query(PermissionView)
+            .filter(PermissionView.id.in_(original_permission_ids))
+            .all()
+        )
+        db.session.commit()
+        for permission_name, view_name in permission_pairs:
+            permission = security_manager.find_permission_view_menu(
+                permission_name, view_name
+            )
+            if permission is not None:
+                for role in list(permission.role):
+                    role.permissions.remove(permission)
+                db.session.commit()
+                security_manager.del_permission_view_menu(permission_name, view_name)
+            security_manager.del_view_menu(view_name)
diff --git a/tests/unit_tests/dashboards/datasets_test.py b/tests/unit_tests/dashboards/datasets_test.py
index 1ab04a3f163..414c925576d 100644
--- a/tests/unit_tests/dashboards/datasets_test.py
+++ b/tests/unit_tests/dashboards/datasets_test.py
@@ -17,8 +17,10 @@
 
 """Dashboard datasource payload regression tests."""
 
+import uuid
 from collections.abc import Iterator
-from typing import Any
+from contextlib import contextmanager
+from typing import Any, Callable, NamedTuple
 from unittest.mock import MagicMock, patch, PropertyMock
 
 import pyarrow as pa
@@ -26,14 +28,32 @@ import pytest
 from flask import g
 from superset_core.semantic_layers.types import Dimension, Grains
 
-from superset.connectors.sqla.models import BaseDatasource, SqlaTable
-from superset.dashboards.api import DashboardRestApi
+from superset import security_manager
+from superset.connectors.sqla.models import (
+    BaseDatasource,
+    SqlaTable,
+    SqlMetric,
+    TableColumn,
+)
+from superset.dashboards.api import (
+    DASHBOARD_DATASET_INACCESSIBLE_FIELDS,
+    DashboardRestApi,
+)
 from superset.dashboards.schemas import DashboardDatasetSchema
+from superset.extensions import feature_flag_manager
 from superset.models.core import Database
-from superset.models.dashboard import Dashboard
+from superset.models.dashboard import Dashboard, saved_chart_display_labels
+from superset.models.embedded_dashboard import EmbeddedDashboard
 from superset.models.slice import Slice
-from superset.security.guest_token import GuestToken, GuestUser
+from superset.security.guest_token import (
+    GuestToken,
+    GuestTokenResourceType,
+    GuestUser,
+)
 from superset.semantic_layers.models import SemanticView
+from superset.subjects.models import Subject
+from superset.subjects.types import SubjectType
+from superset.utils import json
 
 pytestmark: pytest.MarkDecorator = pytest.mark.usefixtures("semantic_layers_enabled")
 
@@ -440,3 +460,1354 @@ def test_dashboard_datasets_isolate_ambiguous_view(
     else:
         assert "metadata_error" not in datasets["2__semantic_view"]
         implementation.get_dimensions.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Authorized chart display labels (``chart_display_labels``)
+# ---------------------------------------------------------------------------
+
+DASHBOARD_UUID: str = "4a4b1d5c-0ae8-4c4f-9e8d-2b8f3a0c1d11"
+OTHER_DASHBOARD_UUID: str = "9f0c6c55-7d43-4b45-a3f0-6f3d1c2b7e22"
+SENSITIVE_LABELS: tuple[str, ...] = ("Metro area", "Margin", "Cost center")
+GUEST_REDACTED_FIELDS: tuple[str, ...] = (
+    "database",
+    "parent",
+    "semantic_view_features",
+    "editors",
+    "sql",
+    "select_star",
+    "perm",
+    "edit_url",
+    "fetch_values_predicate",
+    "template_params",
+)
+
+
+def _labelled_table(table_id: int = 7) -> SqlaTable:
+    """A dataset with labelled, unlabelled and unused columns and metrics."""
+    return SqlaTable(
+        id=table_id,
+        table_name=f"sales_{table_id}",
+        database=Database(id=1, database_name="Warehouse", sqlalchemy_uri="sqlite://"),
+        columns=[
+            TableColumn(column_name="region", verbose_name="Region", type="TEXT"),
+            TableColumn(column_name="metro", verbose_name="Metro area", type="TEXT"),
+            TableColumn(column_name="venue", verbose_name=None, type="TEXT"),
+            TableColumn(
+                column_name="cost_center", verbose_name="Cost center", type="TEXT"
+            ),
+        ],
+        metrics=[
+            SqlMetric(
+                metric_name="revenue", expression="SUM(amount)", verbose_name="Revenue"
+            ),
+            SqlMetric(
+                metric_name="venues",
+                expression="COUNT(DISTINCT venue)",
+                verbose_name="   ",
+            ),
+            SqlMetric(
+                metric_name="margin", expression="SUM(margin)", verbose_name="Margin"
+            ),
+        ],
+    )
+
+
+def _chart(chart_id: int, table: SqlaTable, **params: Any) -> Slice:
+    """A saved member chart on ``table``."""
+    chart: Slice = Slice(
+        id=chart_id,
+        slice_name=f"Chart {chart_id}",
+        datasource_id=table.id,
+        datasource_type="table",
+        viz_type="ag-grid-pivot-table",
+        params=json.dumps(params),
+    )
+    chart.table = table
+    return chart
+
+
+def _viewer(subject_id: int) -> Subject:
+    """An explicit chart viewer subject."""
+    return Subject(id=subject_id, label=f"Viewer {subject_id}", type=SubjectType.USER)
+
+
+def _embedded_dashboard(dashboard_id: int, slices: list[Slice]) -> Dashboard:
+    """A dashboard embedded under ``DASHBOARD_UUID``."""
+    return Dashboard(
+        id=dashboard_id,
+        slices=slices,
+        embedded=[EmbeddedDashboard(uuid=uuid.UUID(DASHBOARD_UUID))],
+    )
+
+
+def _guest(dashboard_ref: str, datasets: list[int] | None = None) -> GuestUser:
+    """A real guest principal whose token grants one dashboard."""
+    token: GuestToken = {
+        "iat": 0,
+        "exp": 9999999999,
+        "user": {"username": "embedded-guest"},
+        "resources": [{"type": GuestTokenResourceType.DASHBOARD, "id": dashboard_ref}],
+        "rls_rules": [],
+    }
+    if datasets is not None:
+        token["datasets"] = datasets
+    return GuestUser(token, roles=[])
+
+
+@contextmanager
+def _as_guest(guest: GuestUser) -> Iterator[None]:
+    """Act as an embedded guest holding no standalone datasource grant.
+
+    Chart and dashboard access run through the real security manager; only the
+    datasource grant the token never carries is pinned to its real outcome.
+    """
+    previous: Callable[[str, bool], bool] | None = (
+        feature_flag_manager._is_feature_enabled_func
+    )
+
+    def resolve(name: str, default: bool) -> bool:
+        if name == "EMBEDDED_SUPERSET":
+            return True
+        return previous(name, default) if previous else default
+
+    with (
+        patch.object(feature_flag_manager, "_is_feature_enabled_func", resolve),
+        patch.object(g, "user", guest, create=True),
+        patch(
+            "superset.models.dashboard.security_manager.get_guest_user_from_request",
+            return_value=guest,
+        ),
+        patch(
+            "superset.models.dashboard.security_manager.can_access_datasource",
+            return_value=False,
+        ),
+    ):
+        yield
+
+
+@contextmanager
+def _as_regular_user(
+    *,
+    admin: bool = False,
+    can_access_datasource: bool = True,
+    viewer_of: Callable[[Any], bool] = lambda resource: False,
+) -> Iterator[None]:
+    """Act as a non-guest principal with the given grants."""
+    with (
+        patch(
+            "superset.models.dashboard.security_manager.is_guest_user",
+            return_value=False,
+        ),
+        patch(
+            "superset.models.dashboard.security_manager.is_admin", return_value=admin
+        ),
+        patch(
+            "superset.models.dashboard.security_manager.is_editor",
+            return_value=admin,
+        ),
+        patch(
+            "superset.models.dashboard.security_manager.is_viewer",
+            side_effect=lambda resource: admin or viewer_of(resource),
+        ),
+        patch(
+            "superset.models.dashboard.security_manager.can_access_datasource",
+            return_value=can_access_datasource,
+        ),
+    ):
+        yield
+
+
+class _DatasetsCall(NamedTuple):
+    response: Any
+    prefetch: MagicMock
+    label_checks: MagicMock
+
+
+def _get_datasets(client: Any, dashboard: Dashboard) -> _DatasetsCall:
+    """Request the datasets endpoint by UUID, which loads the dashboard and then
+    runs the real dashboard access check before any payload is built.
+
+    Records the chart-access prefetch and every real per-chart label access check.
+    """
+    with (
+        patch.object(Dashboard, "get", return_value=dashboard),
+        patch("superset.daos.dashboard.DashboardDAO.prefetch_chart_access") as prefetch,
+        patch(
+            "superset.models.dashboard.security_manager.can_access_dashboard_chart_labels",
+            wraps=security_manager.can_access_dashboard_chart_labels,
+        ) as label_checks,
+    ):
+        response: Any = client.get(f"/api/v1/dashboard/{DASHBOARD_UUID}/datasets")
+    return _DatasetsCall(response, prefetch, label_checks)
+
+
+def _checked_chart_ids(call: _DatasetsCall) -> set[int]:
+    return {args.args[1].id for args in call.label_checks.call_args_list}
+
+
+def _assert_string_labels(labels: dict[str, Any]) -> None:
+    for chart_id, chart_labels in labels.items():
+        assert isinstance(chart_id, str)
+        assert chart_labels
+        for raw_id, label in chart_labels.items():
+            assert isinstance(raw_id, str)
+            assert isinstance(label, str)
+
+
+def test_saved_chart_display_labels_allowlist_saved_fields() -> None:
+    """Only saved dimensions/metrics with configured labels are emitted."""
+    table: SqlaTable = _labelled_table()
+    chart: Slice = _chart(
+        11,
+        table,
+        groupby=[
+            "region",
+            "venue",
+            {
+                "label": "Region bucket",
+                "sqlExpression": "UPPER(cost_center)",
+                "expressionType": "SQL",
+                "hasCustomLabel": True,
+            },
+            {
+                "label": "LOWER(metro)",
+                "sqlExpression": "LOWER(metro)",
+                "expressionType": "SQL",
+            },
+        ],
+        metrics=[
+            "revenue",
+            "venues",
+            "not_a_saved_metric",
+            {
+                "expressionType": "SIMPLE",
+                "aggregate": "MAX",
+                "column": {"column_name": "metro", "verbose_name": "Metro area"},
+                "label": "MAX(metro)",
+                "hasCustomLabel": False,
+            },
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "SUM(margin) / COUNT(*)",
+                "label": "Average margin",
+                "hasCustomLabel": True,
+            },
+        ],
+        adhoc_filters=[
+            {
+                "clause": "WHERE",
+                "subject": "cost_center",
+                "operator": "==",
+                "comparator": "Sales",
+                "expressionType": "SIMPLE",
+            }
+        ],
+        # Saved pivot axes outside the configured dimensions are not labels.
+        pivot_table_state={
+            "rowGroup": {"groupColIds": ["region", "cost_center"]},
+            "pivot": {"pivotMode": True, "pivotColIds": ["metro"]},
+        },
+        # Comparison headers derive from the base metric label client-side.
+        time_compare=["1 year ago"],
+        comparison_type="percentage",
+        order_by_cols=["metro"],
+        timeseries_limit_metric="margin",
+    )
+
+    labels: dict[str, str] = saved_chart_display_labels(table, chart)
+
+    assert labels == {
+        "region": "Region",
+        "Region bucket": "Region bucket",
+        "revenue": "Revenue",
+        "Average margin": "Average margin",
+    }
+    _assert_string_labels({"11": labels})
+
+
+@pytest.mark.parametrize(
+    "params, expected",
+    [
+        (None, {}),
+        ("", {}),
+        ("not json", {}),
+        ("null", {}),
+        ('"region"', {}),
+        ('["region"]', {}),
+        (
+            '{"groupby": "region", "metrics": "revenue"}',
+            {},
+        ),
+        ('{"groupby": {"region": 1}, "metrics": 5}', {}),
+        (
+            '{"groupby": [null, 3, ["region"], {"label": 5, "hasCustomLabel": true},'
+            ' {"label": "", "hasCustomLabel": true}],'
+            ' "metrics": [{"label": "x", "hasCustomLabel": "true"},'
+            ' {"label": "  ", "hasCustomLabel": true}, {"metric_name": "revenue"}]}',
+            {},
+        ),
+    ],
+)
+def test_saved_chart_display_labels_tolerate_malformed_params(
+    params: str | None, expected: dict[str, str]
+) -> None:
+    """Malformed saved params never raise and never invent labels."""
+    table: SqlaTable = _labelled_table()
+    chart: Slice = _chart(11, table)
+    chart.params = params
+
+    assert saved_chart_display_labels(table, chart) == expected
+
+
+def test_saved_chart_display_labels_require_string_verbose_names() -> None:
+    """Non-string stored labels fall back to the raw name."""
+    table: SqlaTable = _labelled_table()
+    table.columns[0].verbose_name = 5
+    table.metrics[0].verbose_name = ["Revenue"]
+    chart: Slice = _chart(11, table, groupby=["region"], metrics=["revenue"])
+
+    assert saved_chart_display_labels(table, chart) == {}
+
+
+@pytest.mark.parametrize("admin", [True, False])
+def test_dashboard_datasets_full_access_keeps_verbose_map_without_label_work(
+    client: Any, full_api_access: None, admin: bool
+) -> None:
+    """Datasource-authorized callers use existing labels without extra work."""
+    table: SqlaTable = _labelled_table()
+    chart: Slice = _chart(
+        11, table, groupby=["region", "metro", "venue"], metrics=["revenue"]
+    )
+    dashboard: Dashboard = Dashboard(
+        id=17, slices=[chart], published=True, viewers=[_viewer(1)]
+    )
+
+    with (
+        _as_regular_user(admin=admin, viewer_of=lambda resource: resource is dashboard),
+        patch("superset.models.dashboard._dataset_display_labels") as index_labels,
+        patch("superset.models.dashboard.saved_chart_display_labels") as chart_labels,
+    ):
+        call: _DatasetsCall = _get_datasets(client, dashboard)
+
+    response: Any = call.response
+    assert response.status_code == 200
+    call.prefetch.assert_not_called()
+    call.label_checks.assert_not_called()
+    index_labels.assert_not_called()
+    chart_labels.assert_not_called()
+    [dataset] = response.json["result"]
+    assert dataset["verbose_map"]["metro"] == "Metro area"
+    assert dataset["columns"]
+    assert "chart_display_labels" not in dataset
+
+
+def test_dashboard_datasets_dashboard_only_principal_gets_authorized_labels(
+    client: Any, full_api_access: None
+) -> None:
+    """Labels follow per-chart access, not dataset access or other dashboards.
+
+    Two member charts share one dataset; the second names another viewer (an
+    explicit chart ACL denial). A third chart on the same dataset that the
+    caller can view belongs to a different dashboard.
+    """
+    table: SqlaTable = _labelled_table()
+    allowed: Slice = _chart(11, table, groupby=["region"], metrics=["revenue"])
+    allowed.viewers = [_viewer(1)]
+    denied: Slice = _chart(12, table, groupby=["metro"], metrics=["margin"])
+    denied.viewers = [_viewer(2)]
+    outside: Slice = _chart(13, table, groupby=["cost_center"], metrics=["margin"])
+    outside.viewers = [_viewer(1)]
+    Dashboard(id=18, slices=[outside])
+    dashboard: Dashboard = Dashboard(
+        id=17, slices=[allowed, denied], published=True, viewers=[_viewer(1)]
+    )
+
+    with _as_regular_user(
+        can_access_datasource=False,
+        viewer_of=lambda resource: resource in (dashboard, allowed, outside),
+    ):
+        assert security_manager.can_access_chart(outside)
+        assert not security_manager.can_access_chart(denied)
+        call: _DatasetsCall = _get_datasets(client, dashboard)
+
+    response: Any = call.response
+    assert response.status_code == 200
+    # One lazy prefetch, then the real check for each labelled member chart.
+    call.prefetch.assert_called_once_with(dashboard)
+    assert _checked_chart_ids(call) == {11, 12}
+    [dataset] = response.json["result"]
+    assert dataset["chart_display_labels"] == {
+        "11": {"region": "Region", "revenue": "Revenue"}
+    }
+    for key in DASHBOARD_DATASET_INACCESSIBLE_FIELDS:
+        assert key not in dataset
+    body: str = response.get_data(as_text=True)
+    for label in SENSITIVE_LABELS:
+        assert label not in body
+    for raw_id in ('"metro"', '"margin"', '"cost_center"', '"12"', '"13"'):
+        assert raw_id not in body
+
+
+@pytest.mark.parametrize("full_access_first", [True, False])
+def test_dashboard_datasets_skip_label_work_only_for_accessible_datasource(
+    client: Any, full_api_access: None, full_access_first: bool
+) -> None:
+    """A datasource grant does not suppress labels for other member datasets."""
+    accessible_table = _labelled_table(7)
+    restricted_table = _labelled_table(8)
+    full_access_chart = _chart(11, accessible_table, groupby=["region"])
+    restricted_chart = _chart(21, restricted_table, groupby=["metro"])
+    restricted_chart.viewers = [_viewer(1)]
+    charts = [full_access_chart, restricted_chart]
+    dashboard = Dashboard(
+        id=17,
+        slices=charts if full_access_first else list(reversed(charts)),
+        published=True,
+        viewers=[_viewer(1)],
+    )
+
+    with (
+        _as_regular_user(
+            viewer_of=lambda resource: resource in (dashboard, restricted_chart),
+        ),
+        patch(
+            "superset.models.dashboard.security_manager.can_access_datasource",
+            side_effect=lambda datasource: datasource is accessible_table,
+        ) as datasource_checks,
+    ):
+        call = _get_datasets(client, dashboard)
+
+    assert call.response.status_code == 200
+    call.prefetch.assert_called_once_with(dashboard)
+    assert _checked_chart_ids(call) == {21}
+    assert datasource_checks.call_count == 2
+    datasource_checks.assert_any_call(accessible_table)
+    datasource_checks.assert_any_call(restricted_table)
+    datasets = {item["id"]: item for item in call.response.json["result"]}
+    assert datasets[7]["verbose_map"]["region"] == "Region"
+    assert "chart_display_labels" not in datasets[7]
+    assert "verbose_map" not in datasets[8]
+    assert datasets[8]["chart_display_labels"] == {"21": {"metro": "Metro area"}}
+
+
+def test_dashboard_datasets_datasource_access_cache_is_request_local(
+    client: Any, full_api_access: None
+) -> None:
+    """A prior request's datasource grant cannot affect a later caller's labels."""
+    table = _labelled_table()
+    chart = _chart(11, table, groupby=["region"])
+    dashboard = Dashboard(id=17, slices=[chart])
+
+    with _as_regular_user(admin=True):
+        full_access = _get_datasets(client, dashboard)
+    with _as_regular_user(admin=True, can_access_datasource=False):
+        restricted = _get_datasets(client, dashboard)
+
+    assert full_access.response.status_code == restricted.response.status_code == 200
+    [full_dataset] = full_access.response.json["result"]
+    [restricted_dataset] = restricted.response.json["result"]
+    assert full_dataset["verbose_map"]["region"] == "Region"
+    assert "chart_display_labels" not in full_dataset
+    assert "verbose_map" not in restricted_dataset
+    assert restricted_dataset["chart_display_labels"] == {"11": {"region": "Region"}}
+
+
+def test_dashboard_datasets_omit_labels_when_none_configured(
+    client: Any, full_api_access: None
+) -> None:
+    """No configured labels, or no accessible chart, means no field at all."""
+    table: SqlaTable = _labelled_table()
+    unlabelled: Slice = _chart(11, table, groupby=["venue"], metrics=["venues"])
+    denied: Slice = _chart(12, table, groupby=["region"], metrics=["revenue"])
+    denied.viewers = [_viewer(2)]
+    dashboard: Dashboard = Dashboard(
+        id=17, slices=[unlabelled, denied], published=True, viewers=[_viewer(1)]
+    )
+
+    with _as_regular_user(
+        can_access_datasource=False,
+        viewer_of=lambda resource: resource in (dashboard, unlabelled),
+    ):
+        call: _DatasetsCall = _get_datasets(client, dashboard)
+
+    response: Any = call.response
+    assert response.status_code == 200
+    assert _checked_chart_ids(call) == {12}
+    [dataset] = response.json["result"]
+    assert "chart_display_labels" not in dataset
+    assert "verbose_map" not in dataset
+
+
+@pytest.mark.parametrize("principal", ["admin", "dashboard_viewer", "guest"])
+def test_dashboard_datasets_without_labels_skip_access_work(
+    client: Any, full_api_access: None, principal: str
+) -> None:
+    """Dashboards without configured labels pay for no access checks."""
+    table: SqlaTable = _labelled_table()
+    chart: Slice = _chart(11, table, groupby=["venue"], metrics=["venues"])
+    chart.viewers = [_viewer(1)]
+    dashboard: Dashboard = _embedded_dashboard(17, [chart])
+    dashboard.published = True
+    dashboard.viewers = [_viewer(1)]
+
+    with (
+        _as_guest(_guest(DASHBOARD_UUID))
+        if principal == "guest"
+        else _as_regular_user(
+            admin=principal == "admin",
+            can_access_datasource=False,
+            viewer_of=lambda resource: resource in (dashboard, chart),
+        )
+    ):
+        call: _DatasetsCall = _get_datasets(client, dashboard)
+
+    assert call.response.status_code == 200
+    [dataset] = call.response.json["result"]
+    assert "chart_display_labels" not in dataset
+    call.prefetch.assert_not_called()
+    call.label_checks.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    "allowlist, expected",
+    [
+        (
+            None,
+            {
+                "sales_7": {"11": {"region": "Region", "revenue": "Revenue"}},
+                "sales_8": {"21": {"metro": "Metro area", "revenue": "Revenue"}},
+            },
+        ),
+        ([7], {"sales_7": {"11": {"region": "Region", "revenue": "Revenue"}}}),
+        ([], {}),
+        (["7"], {}),
+    ],
+)
+def test_dashboard_datasets_guest_labels_follow_token(
+    client: Any,
+    full_api_access: None,
+    allowlist: list[Any] | None,
+    expected: dict[str, dict[str, dict[str, str]]],
+) -> None:
+    """A real guest gets labels for granted charts only, with all redaction."""
+    table: SqlaTable = _labelled_table(7)
+    excluded_table: SqlaTable = _labelled_table(8)
+    allowed: Slice = _chart(11, table, groupby=["region"], metrics=["revenue"])
+    excluded: Slice = _chart(21, excluded_table, groupby=["metro"], metrics=["revenue"])
+    dashboard: Dashboard = _embedded_dashboard(17, [allowed, excluded])
+
+    with _as_guest(_guest(DASHBOARD_UUID, allowlist)):
+        call: _DatasetsCall = _get_datasets(client, dashboard)
+        # The guest path decides without the per-chart check, and decides
+        # exactly as that check would for these member charts.
+        chart_access: set[str] = {
+            str(chart.id)
+            for chart in (allowed, excluded)
+            if security_manager.can_access_chart(chart)
+        }
+
+    response: Any = call.response
+    assert response.status_code == 200
+    call.prefetch.assert_not_called()
+    assert _checked_chart_ids(call) == {11, 21}
+    assert chart_access == {
+        chart_id for labels in expected.values() for chart_id in labels
+    }
+    datasets: dict[str, dict[str, Any]] = {
+        item["table_name"]: item for item in response.json["result"]
+    }
+    assert set(datasets) == {"sales_7", "sales_8"}
+    for name, dataset in datasets.items():
+        assert dataset.get("chart_display_labels") == expected.get(name)
+        for key in (*DASHBOARD_DATASET_INACCESSIBLE_FIELDS, *GUEST_REDACTED_FIELDS):
+            assert key not in dataset
+    if "sales_8" not in expected:
+        assert "Metro area" not in response.get_data(as_text=True)
+
+
+def test_dashboard_datasets_guest_allowlist_applies_to_chart_viewer_grants(
+    client: Any, full_api_access: None
+) -> None:
+    """An explicit chart viewer grant admits the chart, not excluded metadata."""
+    table: SqlaTable = _labelled_table(7)
+    viewed: Slice = _chart(11, table, groupby=["region"], metrics=["revenue"])
+    viewed.viewers = [_viewer(1)]
+    dashboard: Dashboard = _embedded_dashboard(17, [viewed])
+
+    with (
+        _as_guest(_guest(DASHBOARD_UUID, [8])),
+        patch(
+            "superset.models.dashboard.security_manager.is_viewer",
+            side_effect=lambda resource: resource is viewed,
+        ),
+    ):
+        assert security_manager.can_access_chart(viewed)
+        call: _DatasetsCall = _get_datasets(client, dashboard)
+
+    response: Any = call.response
+    assert response.status_code == 200
+    assert _checked_chart_ids(call) == {11}
+    [dataset] = response.json["result"]
+    assert "chart_display_labels" not in dataset
+    assert "Region" not in response.get_data(as_text=True)
+
+
+def test_dashboard_datasets_guest_token_for_other_dashboard_is_denied(
+    client: Any, full_api_access: None
+) -> None:
+    """A token for another dashboard gets no payload, nor labels via the model."""
+    table: SqlaTable = _labelled_table()
+    shared: Slice = _chart(11, table, groupby=["region"], metrics=["revenue"])
+    dashboard: Dashboard = _embedded_dashboard(17, [shared])
+    Dashboard(
+        id=18,
+        slices=[shared],
+        embedded=[EmbeddedDashboard(uuid=uuid.UUID(OTHER_DASHBOARD_UUID))],
+    )
+
+    with _as_guest(_guest(OTHER_DASHBOARD_UUID)):
+        response: Any = _get_datasets(client, dashboard).response
+        # The shared chart is reachable through the token's own dashboard, so
+        # only the requested dashboard's grant keeps its labels out.
+        assert security_manager.can_access_chart(shared)
+        datasets: list[tuple[BaseDatasource | SemanticView, dict[str, Any]]] = (
+            dashboard.datasets_trimmed_for_slices()
+        )
+
+    assert response.status_code == 403
+    assert "Region" not in response.get_data(as_text=True)
+    assert len(datasets) == 1
+    assert "chart_display_labels" not in datasets[0][1]
+
+
+@pytest.mark.parametrize("semantic_access", [True, False])
+def test_chart_display_labels_are_sqla_only_with_colliding_ids(
+    semantic_view: SemanticView, semantic_access: bool
+) -> None:
+    """A same-id semantic view never gets labels nor, if denied, a provider."""
+    table: SqlaTable = _labelled_table(semantic_view.id)
+    table_chart: Slice = _chart(11, table, groupby=["region"], metrics=["revenue"])
+    semantic_chart: Slice = Slice(
+        id=12,
+        datasource_id=semantic_view.id,
+        datasource_type="semantic_view",
+        params=json.dumps({"groupby": ["category"], "metrics": ["revenue"]}),
+    )
+    semantic_chart.semantic_view = semantic_view
+    dashboard: Dashboard = Dashboard(slices=[table_chart, semantic_chart])
+    provider: MagicMock = MagicMock()
+    implementation: PropertyMock
+    with (
+        _as_regular_user(admin=True),
+        patch(
+            "superset.models.dashboard.security_manager.can_access_datasource",
+            side_effect=lambda datasource: datasource is not table and semantic_access,
+        ),
+        patch.object(
+            SemanticView,
+            "implementation",
+            new_callable=PropertyMock,
+            return_value=provider,
+        ) as implementation,
+        patch.object(
+            SemanticView,
+            "data_for_slices",
+            return_value={"id": semantic_view.id, "type": "semantic_view"},
+        ),
+    ):
+        datasets: list[tuple[BaseDatasource | SemanticView, dict[str, Any]]] = (
+            dashboard.datasets_trimmed_for_slices()
+        )
+
+    payloads: dict[tuple[str, int], dict[str, Any]] = {
+        (datasource.type, datasource.id): payload for datasource, payload in datasets
+    }
+    assert payloads[("table", semantic_view.id)]["chart_display_labels"] == {
+        "11": {"region": "Region", "revenue": "Revenue"}
+    }
+    assert "chart_display_labels" not in payloads[("semantic_view", semantic_view.id)]
+    if not semantic_access:
+        implementation.assert_not_called()
+        assert provider.mock_calls == []
+
+
+@pytest.mark.parametrize("is_guest", [True, False])
+def test_chart_display_labels_survive_narrowing_that_strips_metadata(
+    is_guest: bool,
+) -> None:
+    """The string-only label map is whitelisted; dataset metadata is not."""
+    table: SqlaTable = _labelled_table()
+    labels: dict[str, dict[str, str]] = {"11": {"region": "Region"}}
+    payload: dict[str, Any] = {
+        "id": table.id,
+        "uid": f"{table.id}__table",
+        "type": "table",
+        "table_name": table.table_name,
+        "metadata_error": "error",
+        "sql": "SELECT secret FROM t",
+        "select_star": "SELECT *",
+        "fetch_values_predicate": "1 = 1",
+        "template_params": "{}",
+        "params": "{}",
+        "perm": "[Warehouse].[t]",
+        "edit_url": "/edit",
+        "database": {"id": 1, "name": "Warehouse"},
+        "parent": {"name": "Warehouse"},
+        "semantic_view_features": ["feature"],
+        "columns": [{"column_name": "metro"}],
+        "column_names": ["metro"],
+        "column_types": [1],
+        "metrics": [{"metric_name": "margin"}],
+        "verbose_map": {"metro": "Metro area"},
+        "order_by_choices": [["metro", "metro"]],
+        "main_dttm_col": "ds",
+        "granularity_sqla": [["ds", "ds"]],
+        "time_grain_sqla": [["P1D", "Day"]],
+        "editors": [],
+        "chart_display_labels": labels,
+    }
+    with (
+        patch(
+            "superset.dashboards.api.security_manager.can_access_datasource",
+            return_value=False,
+        ),
+        patch(
+            "superset.dashboards.schemas.security_manager.is_guest_user",
+            return_value=is_guest,
+        ),
+    ):
+        serialized: dict[str, Any] = DashboardRestApi()._serialize_dashboard_dataset(
+            table, payload
+        )
+
+    assert serialized["chart_display_labels"] == labels
+    for key in DASHBOARD_DATASET_INACCESSIBLE_FIELDS:
+        assert key not in serialized
+    if is_guest:
+        for key in GUEST_REDACTED_FIELDS:
+            assert key not in serialized
+    assert "Metro area" not in json.dumps(serialized)
+
+
+def test_inaccessible_fields_keep_every_entry_and_exclude_labels() -> None:
+    """Whitelisting labels must not shrink the dataset redaction list."""
+    assert set(DASHBOARD_DATASET_INACCESSIBLE_FIELDS) == {
+        "metadata_error",
+        "sql",
+        "select_star",
+        "fetch_values_predicate",
+        "template_params",
+        "params",
+        "perm",
+        "edit_url",
+        "database",
+        "parent",
+        "semantic_view_features",
+        "columns",
+        "column_names",
+        "column_types",
+        "metrics",
+        "verbose_map",
+        "order_by_choices",
+        "main_dttm_col",
+        "granularity_sqla",
+        "time_grain_sqla",
+    }
+    assert "chart_display_labels" not in DASHBOARD_DATASET_INACCESSIBLE_FIELDS
+
+
+@pytest.mark.parametrize(
+    "viz_type, params, expected",
+    [
+        ("table", {"all_columns": ["region"]}, {"region": "Region"}),
+        (
+            "table",
+            {
+                "groupby": ["region"],
+                "metrics": ["revenue"],
+                "percent_metrics": ["margin"],
+            },
+            {"region": "Region", "revenue": "Revenue", "margin": "Margin"},
+        ),
+        (
+            "pivot_table_v2",
+            {
+                "groupbyRows": ["region"],
+                "groupbyColumns": ["metro"],
+                "metrics": ["revenue"],
+            },
+            {"region": "Region", "metro": "Metro area", "revenue": "Revenue"},
+        ),
+        (
+            "echarts_timeseries_line",
+            {"x_axis": "region", "groupby": ["metro"], "metrics": ["revenue"]},
+            {"region": "Region", "metro": "Metro area", "revenue": "Revenue"},
+        ),
+        (
+            "mixed_timeseries",
+            {"metrics_b": ["revenue"], "groupby_b": ["region"]},
+            {"region": "Region", "revenue": "Revenue"},
+        ),
+        ("big_number", {"metric": "revenue"}, {"revenue": "Revenue"}),
+        (
+            "pie",
+            {"groupby": ["region"], "metric": "revenue"},
+            {"region": "Region", "revenue": "Revenue"},
+        ),
+        (
+            "bubble",
+            {
+                "entity": "region",
+                "series": "metro",
+                "x": "revenue",
+                "y": "margin",
+                "size": "revenue",
+            },
+            {
+                "region": "Region",
+                "metro": "Metro area",
+                "revenue": "Revenue",
+                "margin": "Margin",
+            },
+        ),
+        (
+            "point-cluster-map",
+            {"all_columns_x": "region", "all_columns_y": "metro"},
+            {"region": "Region", "metro": "Metro area"},
+        ),
+        (
+            "sankey",
+            {"source": "region", "target": "metro"},
+            {"region": "Region", "metro": "Metro area"},
+        ),
+        ("histogram", {"column": "region"}, {"region": "Region"}),
+        (
+            "candlestick",
+            {
+                "x_axis": "region",
+                "series": ["metro"],
+                "open": "revenue",
+                "close": "margin",
+                "high": "revenue",
+                "low": "margin",
+            },
+            {
+                "region": "Region",
+                "metro": "Metro area",
+                "revenue": "Revenue",
+                "margin": "Margin",
+            },
+        ),
+        (
+            "tree_chart",
+            {"id": "region", "parent": "metro", "name": "region"},
+            {"region": "Region", "metro": "Metro area"},
+        ),
+        (
+            "heatmap",
+            {"all_columns_x": ["region", "metro"]},
+            {"region": "Region", "metro": "Metro area"},
+        ),
+        (
+            "echarts_timeseries_line",
+            {"series_columns": ["region"], "tooltip_metrics": ["revenue"]},
+            {"region": "Region", "revenue": "Revenue"},
+        ),
+    ],
+)
+@pytest.mark.parametrize("with_query_context", [False, True])
+def test_chart_display_labels_cover_visualization_controls(
+    viz_type: str,
+    params: dict[str, Any],
+    expected: dict[str, str],
+    with_query_context: bool,
+) -> None:
+    """Displayed fields have full-access parity without filter or sort metadata."""
+    table = _labelled_table()
+    table.metrics.append(
+        SqlMetric(
+            metric_name="sorting_metric",
+            expression="COUNT(*)",
+            verbose_name="Sort label",
+        )
+    )
+    chart = _chart(
+        11,
+        table,
+        **params,
+        adhoc_filters=[{"clause": "WHERE", "subject": "cost_center"}],
+        filter_configs=[{"column": "cost_center"}],
+        order_by_cols=["cost_center"],
+        timeseries_limit_metric="sorting_metric",
+        series_limit_metric="sorting_metric",
+    )
+    chart.viz_type = viz_type
+    if with_query_context:
+        chart.query_context = json.dumps(
+            {
+                "queries": [
+                    {
+                        "columns": [
+                            key for key in expected if key in {"region", "metro"}
+                        ],
+                        "metrics": [
+                            key for key in expected if key in {"revenue", "margin"}
+                        ],
+                        "filters": [{"col": "cost_center"}],
+                        "orderby": [["cost_center", True]],
+                        "granularity": "cost_center",
+                    }
+                ]
+            }
+        )
+    labels = saved_chart_display_labels(table, chart)
+    assert labels == expected
+    verbose_map = table.data_for_slices([chart])["verbose_map"]
+    assert all(verbose_map.get(key) == label for key, label in labels.items())
+    assert "cost_center" not in labels
+    assert "sorting_metric" not in labels
+
+
+@pytest.mark.parametrize("reference_kind", ["BASE_AXIS", "column_reference"])
+@pytest.mark.parametrize("exact", [True, False])
+def test_chart_display_labels_only_resolve_exact_physical_references(
+    reference_kind: str, exact: bool
+) -> None:
+    """Physical axis wrappers resolve only when expression and label match."""
+    table = _labelled_table()
+    reference = {
+        "label": "region",
+        "sqlExpression": "region" if exact else "UPPER(region)",
+        "expressionType": "SQL",
+        "hasCustomLabel": False,
+    }
+    if reference_kind == "BASE_AXIS":
+        reference["columnType"] = "BASE_AXIS"
+    else:
+        reference["isColumnReference"] = True
+    chart = _chart(11, table, x_axis=reference)
+    assert saved_chart_display_labels(table, chart) == (
+        {"region": "Region"} if exact else {}
+    )
+
+
+@pytest.mark.parametrize(
+    "query_context",
+    [
+        None,
+        "",
+        "invalid",
+        "null",
+        "[]",
+        '{"queries": null}',
+        '{"queries": [null, 5]}',
+        '{"queries": [{"columns": "metro", "metrics": "margin"}]}',
+    ],
+)
+def test_chart_display_labels_tolerate_malformed_query_context(
+    query_context: str | None,
+) -> None:
+    """Malformed cached queries do not prevent valid saved controls being labelled."""
+    table = _labelled_table()
+    chart = _chart(11, table, groupby=["region"], metrics=["revenue"])
+    chart.query_context = query_context
+    assert saved_chart_display_labels(table, chart) == {
+        "region": "Region",
+        "revenue": "Revenue",
+    }
+
+
+def test_chart_display_labels_custom_adhoc_parity() -> None:
+    """Authored expression labels occur unchanged in the full-access verbose map."""
+    table = _labelled_table()
+    chart = _chart(
+        11,
+        table,
+        groupby=[
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "UPPER(cost_center)",
+                "label": "Cost grouping",
+                "hasCustomLabel": True,
+            }
+        ],
+        metrics=[
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "SUM(margin)",
+                "label": "Total margin",
+                "hasCustomLabel": True,
+            }
+        ],
+    )
+    labels = saved_chart_display_labels(table, chart)
+    assert labels == {"Cost grouping": "Cost grouping", "Total margin": "Total margin"}
+    verbose_map = table.data_for_slices([chart])["verbose_map"]
+    assert all(verbose_map[key] == value for key, value in labels.items())
+    assert "cost_center" not in labels
+    assert "margin" not in labels
+
+
+def test_chart_display_labels_preserve_column_metric_collision_precedence(
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """A shared identifier cannot supply a different label than the full response."""
+    table = _labelled_table()
+    table.metrics.append(
+        SqlMetric(
+            metric_name="region", expression="COUNT(*)", verbose_name="Region count"
+        )
+    )
+    column_chart = _chart(11, table, groupby=["region"])
+    metric_chart = _chart(12, table, metrics=["region"])
+    dashboard = Dashboard(id=17, slices=[column_chart, metric_chart])
+    verbose_map = table.data_for_slices([column_chart, metric_chart])["verbose_map"]
+    with _as_regular_user(admin=True, can_access_datasource=False):
+        response = _get_datasets(client, dashboard).response
+    assert response.status_code == 200
+    [dataset] = response.json["result"]
+    assert verbose_map["region"] == "Region"
+    assert "verbose_map" not in dataset
+    assert dataset["chart_display_labels"] == {"11": {"region": "Region"}}
+
+
+@pytest.mark.parametrize("overridden_method", ["can_access_chart", "raise_for_access"])
+def test_chart_display_labels_honor_existing_custom_guest_authorization(
+    app_context: None,
+    overridden_method: str,
+) -> None:
+    """Existing custom chart restrictions remain effective for embedded labels."""
+    from superset.exceptions import SupersetSecurityException
+
+    table = _labelled_table()
+    chart = _chart(11, table, groupby=["region"])
+    dashboard = _embedded_dashboard(17, [chart])
+    with _as_guest(_guest(DASHBOARD_UUID)):
+        assert security_manager.can_access_dashboard_chart_labels(dashboard, chart)
+        kwargs: dict[str, Any] = (
+            {"return_value": False}
+            if overridden_method == "can_access_chart"
+            else {
+                "side_effect": SupersetSecurityException(
+                    security_manager.get_dashboard_access_error_object(dashboard)
+                )
+            }
+        )
+        with patch.object(
+            security_manager, overridden_method, **kwargs
+        ) as custom_check:
+            assert not security_manager.can_access_dashboard_chart_labels(
+                dashboard, chart
+            )
+        custom_check.assert_called_once()
+
+
+def test_chart_display_labels_ignore_viewer_writable_query_context() -> None:
+    """Cached query outputs cannot expand the editor-controlled label allowlist."""
+    table = _labelled_table()
+    chart = _chart(11, table, groupby=["region"])
+    chart.query_context = json.dumps(
+        {
+            "form_data": {"groupby": ["metro"], "metrics": ["revenue"]},
+            "queries": [
+                {
+                    "columns": [
+                        "metro",
+                        {
+                            "sqlExpression": "UPPER(cost_center)",
+                            "label": "Cost grouping",
+                            "hasCustomLabel": True,
+                        },
+                    ],
+                    "groupby": ["region"],
+                    "metrics": [
+                        "revenue",
+                        {
+                            "expressionType": "SQL",
+                            "sqlExpression": "SUM(margin)",
+                            "label": "SUM(margin)",
+                            "hasCustomLabel": False,
+                        },
+                    ],
+                    "filters": [{"col": "cost_center"}],
+                    "orderby": [["margin", False]],
+                }
+            ],
+        }
+    )
+    labels = saved_chart_display_labels(table, chart)
+    assert labels == {"region": "Region"}
+    verbose_map = table.data_for_slices([chart])["verbose_map"]
+    assert verbose_map["metro"] == "Metro area"
+    assert "Cost grouping" not in verbose_map
+    assert verbose_map["revenue"] == "Revenue"
+
+
+def test_data_for_slices_only_adds_authored_labels_from_saved_controls() -> None:
+    """Query outputs retain stored fields without introducing authored labels."""
+    table = _labelled_table()
+    chart = _chart(
+        11,
+        table,
+        groupby=[
+            "region",
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "UPPER(region)",
+                "label": "Saved grouping",
+                "hasCustomLabel": True,
+            },
+        ],
+        metrics=[
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "COUNT(*)",
+                "label": "Saved count",
+                "hasCustomLabel": True,
+            }
+        ],
+    )
+    chart.query_context = json.dumps(
+        {
+            "queries": [
+                {
+                    "columns": [
+                        "metro",
+                        {
+                            "expressionType": "SQL",
+                            "sqlExpression": "cost_center",
+                            "label": "cost_center",
+                            "isColumnReference": True,
+                        },
+                        {
+                            "expressionType": "SQL",
+                            "sqlExpression": "LOWER(region)",
+                            "label": "Injected grouping",
+                            "hasCustomLabel": True,
+                        },
+                    ],
+                    "metrics": [
+                        "revenue",
+                        {
+                            "expressionType": "SQL",
+                            "sqlExpression": "SUM(margin)",
+                            "label": "Injected margin",
+                            "hasCustomLabel": True,
+                        },
+                    ],
+                }
+            ]
+        }
+    )
+
+    data = table.data_for_slices([chart])
+
+    assert data["verbose_map"] == {
+        "__timestamp": "Time",
+        "region": "Region",
+        "metro": "Metro area",
+        "cost_center": "Cost center",
+        "revenue": "Revenue",
+        "Saved grouping": "Saved grouping",
+        "Saved count": "Saved count",
+    }
+    assert {column["column_name"] for column in data["columns"]} == {
+        "region",
+        "metro",
+        "cost_center",
+    }
+    assert {metric["metric_name"] for metric in data["metrics"]} == {"revenue"}
+
+
+@pytest.mark.parametrize("sort_is_displayed", [False, True])
+def test_chart_display_labels_exclude_gantt_sort_only_query_columns(
+    sort_is_displayed: bool,
+) -> None:
+    """Gantt's orderby dependencies in query.columns are not display controls."""
+    table = _labelled_table()
+    sort_column = "region" if sort_is_displayed else "cost_center"
+    chart = _chart(
+        11,
+        table,
+        start_time="region",
+        end_time="metro",
+        y_axis="venue",
+        series=["region"],
+        tooltip_columns=["metro"],
+        tooltip_metrics=["revenue"],
+        order_by_cols=[json.dumps([sort_column, True])],
+    )
+    chart.viz_type = "gantt_chart"
+    # Gantt/buildQuery.ts adds orderby columns to its raw query outputs.
+    chart.query_context = json.dumps(
+        {
+            "queries": [
+                {
+                    "columns": list(
+                        dict.fromkeys(["region", "metro", "venue", sort_column])
+                    ),
+                    "metrics": ["revenue"],
+                    "orderby": [[sort_column, True]],
+                    "series_columns": ["region"],
+                }
+            ]
+        }
+    )
+    assert saved_chart_display_labels(table, chart) == {
+        "region": "Region",
+        "metro": "Metro area",
+        "revenue": "Revenue",
+    }
+    assert sort_column in table.data_for_slices([chart])["verbose_map"]
+
+
+@pytest.mark.parametrize("sort_is_displayed", [False, True])
+def test_chart_display_labels_exclude_butterfly_sort_only_query_metrics(
+    sort_is_displayed: bool,
+) -> None:
+    """Butterfly's added sort metric is labelled only when explicitly displayed."""
+    table = _labelled_table()
+    table.metrics.append(
+        SqlMetric(
+            metric_name="sort_only", expression="COUNT(*)", verbose_name="Sort label"
+        )
+    )
+    sort_metric = "revenue" if sort_is_displayed else "sort_only"
+    chart = _chart(
+        11,
+        table,
+        groupby=["region"],
+        left_metric="revenue",
+        right_metric="margin",
+        orderby=[sort_metric],
+        order_desc=True,
+    )
+    chart.viz_type = "butterfly"
+    # Butterfly/buildQuery.ts uses buildSortMetricOrderby, which appends a
+    # missing sort metric to metrics as well as placing it in orderby.
+    chart.query_context = json.dumps(
+        {
+            "queries": [
+                {
+                    "columns": ["region"],
+                    "metrics": list(dict.fromkeys(["revenue", "margin", sort_metric])),
+                    "orderby": [[sort_metric, False]],
+                }
+            ]
+        }
+    )
+    assert saved_chart_display_labels(table, chart) == {
+        "region": "Region",
+        "revenue": "Revenue",
+        "margin": "Margin",
+    }
+    assert sort_metric in table.data_for_slices([chart])["verbose_map"]
+
+
+@pytest.mark.parametrize("viz_type", ["table", "ag-grid-table", "handlebars"])
+@pytest.mark.parametrize("mode", ["aggregate", "raw", None, "invalid"])
+@pytest.mark.parametrize("all_columns", [["metro", "cost_center"], [], None])
+def test_chart_display_labels_exclude_hidden_table_controls(
+    viz_type: str, mode: str | None, all_columns: list[str] | None
+) -> None:
+    """Retained controls from the other table query mode cannot contribute labels."""
+    table = _labelled_table()
+    chart = _chart(
+        11,
+        table,
+        query_mode=mode,
+        all_columns=all_columns,
+        groupby=["region"],
+        metrics=["revenue"],
+        percent_metrics=["margin"],
+    )
+    chart.viz_type = viz_type
+    chart.query_context = json.dumps(
+        {
+            "queries": [
+                {
+                    "columns": ["metro", "cost_center", "region"],
+                    "metrics": ["revenue", "margin"],
+                }
+            ]
+        }
+    )
+    is_raw = mode == "raw" or (
+        viz_type != "handlebars"
+        and mode not in ("aggregate", "raw")
+        and bool(all_columns)
+    )
+    if is_raw:
+        expected = (
+            {"metro": "Metro area", "cost_center": "Cost center"} if all_columns else {}
+        )
+    else:
+        expected = {"region": "Region", "revenue": "Revenue"}
+        if viz_type != "handlebars":
+            expected["margin"] = "Margin"
+        elif mode not in ("aggregate", "raw") and all_columns:
+            expected.update({"metro": "Metro area", "cost_center": "Cost center"})
+    assert saved_chart_display_labels(table, chart) == expected
+    assert set(table.data_for_slices([chart])["verbose_map"]) >= {
+        "metro",
+        "cost_center",
+        "region",
+        "revenue",
+        "margin",
+    }
+
+
+@pytest.mark.parametrize("model_viz_type", [None, "tree_chart", "table"])
+def test_chart_display_labels_share_saved_viz_type_resolution(
+    model_viz_type: str | None,
+) -> None:
+    """Metadata and labels share the model viz type and its saved-form fallback."""
+    table = _labelled_table()
+    chart = _chart(
+        11,
+        table,
+        viz_type="tree_chart",
+        id="region",
+        parent="metro",
+        name="region",
+    )
+    chart.viz_type = model_viz_type
+    expected = (
+        {} if model_viz_type == "table" else {"region": "Region", "metro": "Metro area"}
+    )
+    assert saved_chart_display_labels(table, chart) == expected
+    verbose_map = table.data_for_slices([chart])["verbose_map"]
+    assert {name: verbose_map[name] for name in expected} == expected
+    if model_viz_type == "table":
+        assert "region" not in verbose_map
+        assert "metro" not in verbose_map
+
+
+def test_chart_display_labels_keep_metadata_ids_outside_tree_controls() -> None:
+    """Generic chart metadata named id/name is not a displayed column reference."""
+    table = _labelled_table()
+    chart = _chart(11, table, id="region", name="metro", parent="cost_center")
+    assert saved_chart_display_labels(table, chart) == {}
+
+
+def test_chart_display_labels_include_explicit_tooltip_columns() -> None:
+    """Tooltip display fields are labelled without exposing malformed contents."""
+    table = _labelled_table()
+    chart = _chart(
+        11,
+        table,
+        dimension="region",
+        tooltip_contents=[
+            "metro",
+            {"item_type": "column", "column_name": "region"},
+            {"item_type": "html", "column_name": "cost_center"},
+            {"item_type": "column", "column_name": ["cost_center"]},
+            None,
+        ],
+    )
+    assert saved_chart_display_labels(table, chart) == {
+        "region": "Region",
+        "metro": "Metro area",
+    }
diff --git a/tests/unit_tests/security/test_dashboard_chart_labels.py b/tests/unit_tests/security/test_dashboard_chart_labels.py
new file mode 100644
index 00000000000..4d9270f11f8
--- /dev/null
+++ b/tests/unit_tests/security/test_dashboard_chart_labels.py
@@ -0,0 +1,207 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Direct-call coverage for dashboard chart label authorization."""
+
+from unittest.mock import MagicMock, patch, PropertyMock
+
+import pytest
+from flask import current_app
+
+from superset.connectors.sqla.models import SqlaTable
+from superset.exceptions import SupersetSecurityException
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
+from superset.security.manager import SupersetSecurityManager
+from superset.subjects.models import Subject
+
+
+@pytest.mark.parametrize("is_guest", [False, True])
+@pytest.mark.parametrize(
+    "is_member,datasource_type",
+    [(False, "table"), (True, "semantic_view"), (True, "table")],
+)
+def test_chart_labels_require_sql_member_chart(
+    is_guest: bool, is_member: bool, datasource_type: str
+) -> None:
+    """The public hook enforces its scope even without the dataset serializer."""
+    chart = Slice(id=11, datasource_id=7, datasource_type=datasource_type)
+    dashboard = Dashboard(id=17, slices=[chart] if is_member else [])
+    manager = MagicMock(spec=SupersetSecurityManager)
+    manager.is_guest_user.return_value = is_guest
+    manager.can_access_chart.return_value = True
+    manager._guest_token_allows_dataset.return_value = True
+
+    expected = is_member and datasource_type == "table"
+    assert (
+        SupersetSecurityManager.can_access_dashboard_chart_labels(
+            manager, dashboard, chart, guest_dashboard_access=True
+        )
+        is expected
+    )
+    if expected:
+        manager.can_access_chart.assert_called_once_with(chart)
+    else:
+        manager.can_access_chart.assert_not_called()
+        manager._guest_token_allows_dataset.assert_not_called()
+
+
+@pytest.mark.parametrize("overridden_method", ["can_access_chart", "raise_for_access"])
+@pytest.mark.parametrize("is_guest", [False, True])
+def test_chart_labels_honor_base_class_access_wrappers(
+    overridden_method: str, is_guest: bool
+) -> None:
+    """Replacing a stock method in place must not bypass operator restrictions."""
+    chart = Slice(id=11, datasource_id=7, datasource_type="table")
+    dashboard = Dashboard(id=17, slices=[chart])
+    manager = object.__new__(SupersetSecurityManager)
+    custom_check = MagicMock()
+
+    def can_access_chart(self: SupersetSecurityManager, chart: Slice) -> bool:
+        """Deny the chart through an operator-installed wrapper."""
+        custom_check(chart)
+        return False
+
+    def raise_for_access(self: SupersetSecurityManager, *, chart: Slice) -> None:
+        """Deny the chart through an operator-installed wrapper."""
+        custom_check(chart)
+        raise SupersetSecurityException(
+            self.get_dashboard_access_error_object(dashboard)
+        )
+
+    replacement = (
+        can_access_chart
+        if overridden_method == "can_access_chart"
+        else raise_for_access
+    )
+    with (
+        patch.object(manager, "is_guest_user", return_value=is_guest),
+        patch.object(manager, "is_editor", return_value=True),
+        patch.object(manager, "_guest_token_allows_dataset", return_value=True),
+        patch.object(SupersetSecurityManager, overridden_method, replacement),
+    ):
+        assert not manager.can_access_dashboard_chart_labels(
+            dashboard, chart, guest_dashboard_access=True, datasource_access=False
+        )
+    custom_check.assert_called_once_with(chart)
+
+
+@pytest.mark.parametrize(
+    "is_admin,is_editor,has_viewers,is_viewer,expected",
+    [
+        pytest.param(True, False, False, False, True, id="admin"),
+        pytest.param(False, True, False, False, True, id="editor"),
+        pytest.param(False, False, True, True, True, id="viewer"),
+        pytest.param(False, False, True, False, False, id="other-viewer"),
+        pytest.param(False, False, False, False, False, id="no-grant"),
+        pytest.param(False, False, False, True, False, id="no-viewer-list"),
+    ],
+)
+def test_chart_labels_reuse_denied_datasource_access(
+    app_context: None,
+    is_admin: bool,
+    is_editor: bool,
+    has_viewers: bool,
+    is_viewer: bool,
+    expected: bool,
+) -> None:
+    """Stock chart grants need no repeated datasource permission queries."""
+    chart = Slice(
+        id=11,
+        datasource_id=7,
+        datasource_type="table",
+        viewers=[Subject(id=23)] if has_viewers else [],
+    )
+    dashboard = Dashboard(id=17, slices=[chart])
+    manager = object.__new__(SupersetSecurityManager)
+    with (
+        patch.dict(current_app.config, {"EXTRA_RAISE_FOR_ACCESS_BYPASS": None}),
+        patch.object(manager, "is_guest_user", return_value=False),
+        patch.object(manager, "is_admin", return_value=is_admin),
+        patch.object(manager, "is_editor", return_value=is_editor),
+        patch.object(manager, "is_viewer", return_value=is_viewer),
+        patch.object(
+            Slice, "resolved_datasource", new_callable=PropertyMock
+        ) as resolved,
+        patch.object(manager, "can_access_datasource") as datasource_check,
+    ):
+        assert (
+            manager.can_access_dashboard_chart_labels(
+                dashboard, chart, datasource_access=False
+            )
+            is expected
+        )
+        resolved.assert_not_called()
+        datasource_check.assert_not_called()
+
+
+@pytest.mark.parametrize("datasource_access", [None, True])
+def test_chart_labels_keep_full_check_without_denied_datasource_hint(
+    app_context: None, datasource_access: bool | None
+) -> None:
+    """Only an explicitly denied dataset decision enables the shortcut."""
+    table = SqlaTable(id=7)
+    chart = Slice(id=11, datasource_id=7, datasource_type="table")
+    dashboard = Dashboard(id=17, slices=[chart])
+    manager = object.__new__(SupersetSecurityManager)
+    with (
+        patch.dict(current_app.config, {"EXTRA_RAISE_FOR_ACCESS_BYPASS": None}),
+        patch.object(manager, "is_guest_user", return_value=False),
+        patch.object(manager, "is_admin", return_value=False),
+        patch.object(manager, "is_editor", return_value=False),
+        patch.object(
+            Slice, "resolved_datasource", new_callable=PropertyMock, return_value=table
+        ),
+        patch.object(manager, "can_access_datasource", return_value=True) as check,
+    ):
+        assert manager.can_access_dashboard_chart_labels(
+            dashboard, chart, datasource_access=datasource_access
+        )
+        check.assert_called_once_with(table)
+
+
+@pytest.mark.parametrize("bypass_grants_access", [False, True])
+def test_chart_labels_preserve_configured_access_bypass(
+    app_context: None, bypass_grants_access: bool
+) -> None:
+    """An operator bypass may grant a chart despite denied datasource access."""
+    table = SqlaTable(id=7)
+    chart = Slice(id=11, datasource_id=7, datasource_type="table")
+    dashboard = Dashboard(id=17, slices=[chart])
+    manager = object.__new__(SupersetSecurityManager)
+    bypass = MagicMock(return_value=bypass_grants_access)
+    with (
+        patch.dict(current_app.config, {"EXTRA_RAISE_FOR_ACCESS_BYPASS": bypass}),
+        patch.object(manager, "is_guest_user", return_value=False),
+        patch.object(manager, "is_admin", return_value=False),
+        patch.object(manager, "is_editor", return_value=False),
+        patch.object(
+            Slice, "resolved_datasource", new_callable=PropertyMock, return_value=table
+        ),
+        patch.object(manager, "can_access_datasource", return_value=False) as check,
+    ):
+        assert (
+            manager.can_access_dashboard_chart_labels(
+                dashboard, chart, datasource_access=False
+            )
+            is bypass_grants_access
+        )
+        bypass.assert_called_once()
+        assert bypass.call_args.kwargs["chart"] is chart
+        if bypass_grants_access:
+            check.assert_not_called()
+        else:
+            check.assert_called_once_with(table)