diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index 89529fafbed..93c260f7156 100644 --- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -301,30 +301,29 @@ export const useHeaderActionsMenu = ({ }); } + // View lineage (available in both view and edit mode; lineage is + // read-only information about the dashboard's upstream assets) + if (dashboardId) { + menuItems.push( + createModalMenuItem( + MenuKeys.ViewLineage, + {t('View lineage')} + } + />, + ), + ); + } + // Edit properties if (editMode) { menuItems.push({ key: MenuKeys.EditProperties, label: t('Edit properties'), }); - - // View lineage - if (dashboardId) { - menuItems.push( - createModalMenuItem( - MenuKeys.ViewLineage, - - {t('View lineage')} - - } - />, - ), - ); - } } // Divider diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx index aad0c5e049a..d816b2b1450 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { useState } from 'react'; import { t } from '@apache-superset/core/translation'; import { styled } from '@apache-superset/core/theme'; import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts'; @@ -65,7 +66,13 @@ const TABS_KEYS = { const EditPage = ({ id }: EditPageProps) => { const { usageCount } = useGetDatasetRelatedCounts(id); - const lineageResource = useDatasetLineage(id); + const [activeKey, setActiveKey] = useState(TABS_KEYS.COLUMNS); + // Only fetch lineage once the user opens the Lineage tab to avoid + // unnecessary requests/backend load on page load. + const lineageResource = useDatasetLineage( + id, + activeKey !== TABS_KEYS.LINEAGE, + ); const usageTab = ( @@ -99,7 +106,14 @@ const EditPage = ({ id }: EditPageProps) => { }, ]; - return ; + return ( + + ); }; export default EditPage; diff --git a/superset-frontend/src/features/lineage/LineageView.tsx b/superset-frontend/src/features/lineage/LineageView.tsx index 1b8b2bbfec0..90b785d7b27 100644 --- a/superset-frontend/src/features/lineage/LineageView.tsx +++ b/superset-frontend/src/features/lineage/LineageView.tsx @@ -136,13 +136,21 @@ const DetailValue = styled.span` `} `; +type NodeType = 'database' | 'dataset' | 'chart' | 'dashboard'; + type NodeDetails = { name: string; - type: 'database' | 'dataset' | 'chart' | 'dashboard'; + type: NodeType; id?: number; additionalInfo?: Record; }; +// Build a stable, unique graph identity for a node so that entities sharing the +// same display name (e.g. two charts with identical titles) never collapse into +// a single Sankey node. The human-readable name is kept separately as the label. +const nodeKey = (type: NodeType, id?: number, name?: string): string => + id != null ? `${type}:${id}` : `${type}:${name ?? ''}`; + type LineageViewProps = { lineageResource: | Resource @@ -171,7 +179,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { const { dataset, upstream, downstream } = data as DatasetLineage; // Add current dataset - map.set(dataset.name, { + map.set(nodeKey('dataset', dataset.id, dataset.name), { name: dataset.name, type: 'dataset', id: dataset.id, @@ -184,17 +192,24 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add upstream database if (upstream?.database) { - map.set(upstream.database.database_name, { - name: upstream.database.database_name, - type: 'database', - id: upstream.database.id, - }); + map.set( + nodeKey( + 'database', + upstream.database.id, + upstream.database.database_name, + ), + { + name: upstream.database.database_name, + type: 'database', + id: upstream.database.id, + }, + ); } // Add downstream charts if (downstream?.charts?.result) { downstream.charts.result.forEach((chart: ChartEntity) => { - map.set(chart.slice_name, { + map.set(nodeKey('chart', chart.id, chart.slice_name), { name: chart.slice_name, type: 'chart', id: chart.id, @@ -208,7 +223,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add downstream dashboards if (downstream?.dashboards?.result) { downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { - map.set(dashboard.title, { + map.set(nodeKey('dashboard', dashboard.id, dashboard.title), { name: dashboard.title, type: 'dashboard', id: dashboard.id, @@ -222,7 +237,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { const { chart, upstream, downstream } = data as ChartLineage; // Add current chart - map.set(chart.slice_name, { + map.set(nodeKey('chart', chart.id, chart.slice_name), { name: chart.slice_name, type: 'chart', id: chart.id, @@ -233,30 +248,40 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add upstream dataset if (upstream?.dataset) { - map.set(upstream.dataset.name, { - name: upstream.dataset.name, - type: 'dataset', - id: upstream.dataset.id, - additionalInfo: { - schema: upstream.dataset.schema, - table_name: upstream.dataset.table_name, + map.set( + nodeKey('dataset', upstream.dataset.id, upstream.dataset.name), + { + name: upstream.dataset.name, + type: 'dataset', + id: upstream.dataset.id, + additionalInfo: { + schema: upstream.dataset.schema, + table_name: upstream.dataset.table_name, + }, }, - }); + ); } // Add upstream database if (upstream?.database) { - map.set(upstream.database.database_name, { - name: upstream.database.database_name, - type: 'database', - id: upstream.database.id, - }); + map.set( + nodeKey( + 'database', + upstream.database.id, + upstream.database.database_name, + ), + { + name: upstream.database.database_name, + type: 'database', + id: upstream.database.id, + }, + ); } // Add downstream dashboards if (downstream?.dashboards?.result) { downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { - map.set(dashboard.title, { + map.set(nodeKey('dashboard', dashboard.id, dashboard.title), { name: dashboard.title, type: 'dashboard', id: dashboard.id, @@ -270,7 +295,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { const { dashboard, upstream } = data as DashboardLineage; // Add current dashboard - map.set(dashboard.title, { + map.set(nodeKey('dashboard', dashboard.id, dashboard.title), { name: dashboard.title, type: 'dashboard', id: dashboard.id, @@ -279,24 +304,10 @@ const LineageView: FC = ({ lineageResource, entityType }) => { }, }); - // First pass: detect duplicate chart names - const chartNameCounts = new Map(); - if (upstream?.charts?.result) { - upstream.charts.result.forEach((chart: ChartEntity) => { - const count = chartNameCounts.get(chart.slice_name) || 0; - chartNameCounts.set(chart.slice_name, count + 1); - }); - } - // Add upstream charts if (upstream?.charts?.result) { upstream.charts.result.forEach((chart: ChartEntity) => { - // Only append ID if there are duplicate names - const hasDuplicate = (chartNameCounts.get(chart.slice_name) || 0) > 1; - const chartNodeName = hasDuplicate - ? `${chart.slice_name} (#${chart.id})` - : chart.slice_name; - map.set(chartNodeName, { + map.set(nodeKey('chart', chart.id, chart.slice_name), { name: chart.slice_name, type: 'chart', id: chart.id, @@ -310,7 +321,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add upstream datasets if (upstream?.datasets?.result) { upstream.datasets.result.forEach((dataset: DatasetEntity) => { - map.set(dataset.name, { + map.set(nodeKey('dataset', dataset.id, dataset.name), { name: dataset.name, type: 'dataset', id: dataset.id, @@ -325,7 +336,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add upstream databases if (upstream?.databases?.result) { upstream.databases.result.forEach((database: DatabaseEntity) => { - map.set(database.database_name, { + map.set(nodeKey('database', database.id, database.database_name), { name: database.database_name, type: 'database', id: database.id, @@ -366,31 +377,34 @@ const LineageView: FC = ({ lineageResource, entityType }) => { const data = lineageResource.result; const nodes: { name: string; + label?: { position?: string; formatter?: string }; itemStyle?: { color: string }; - label?: { position?: string }; }[] = []; const links: { source: string; target: string; value: number }[] = []; const nodeSet = new Set(); - // Helper to add a node with label position + // Helper to add a node. `key` is the stable unique identity used for graph + // links and detail lookups; `label` is the human-readable text shown. const addNode = ( - name: string, + key: string, + label: string, color: string, labelPosition: 'left' | 'right' | 'inside', ) => { - if (!nodeSet.has(name)) { - nodeSet.add(name); + if (!nodeSet.has(key)) { + nodeSet.add(key); nodes.push({ - name, + name: key, itemStyle: { color }, label: { position: labelPosition, + formatter: label, }, }); } }; - // Helper to add a link + // Helper to add a link between two node keys const addLink = (source: string, target: string) => { links.push({ source, target, value: 1 }); }; @@ -399,36 +413,49 @@ const LineageView: FC = ({ lineageResource, entityType }) => { if (entityType === 'dataset' && 'dataset' in data) { const { dataset, upstream, downstream } = data as DatasetLineage; + const datasetKey = nodeKey('dataset', dataset.id, dataset.name); // Add current dataset node (center) - label inside - addNode(dataset.name, theme.colorPrimary, 'inside'); + addNode(datasetKey, dataset.name, theme.colorPrimary, 'inside'); // Add upstream database - label on left if (upstream?.database) { - addNode(upstream.database.database_name, theme.colorInfo, 'left'); - addLink(upstream.database.database_name, dataset.name); + const dbKey = nodeKey( + 'database', + upstream.database.id, + upstream.database.database_name, + ); + addNode( + dbKey, + upstream.database.database_name, + theme.colorInfo, + 'left', + ); + addLink(dbKey, datasetKey); } // Add downstream charts - label on right - const chartMap = new Map(); + const chartKeys = new Map(); if (downstream?.charts?.result) { downstream.charts.result.forEach((chart: ChartEntity) => { - chartMap.set(chart.id, chart); - addNode(chart.slice_name, theme.colorSuccess, 'right'); - addLink(dataset.name, chart.slice_name); + const chartKey = nodeKey('chart', chart.id, chart.slice_name); + chartKeys.set(chart.id, chartKey); + addNode(chartKey, chart.slice_name, theme.colorSuccess, 'right'); + addLink(datasetKey, chartKey); }); } // Add downstream dashboards - label on right if (downstream?.dashboards?.result) { downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { - addNode(dashboard.title, theme.colorWarning, 'right'); + const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title); + addNode(dashKey, dashboard.title, theme.colorWarning, 'right'); // Link from charts to dashboards using chart_ids if (dashboard.chart_ids && dashboard.chart_ids.length > 0) { dashboard.chart_ids.forEach(chartId => { - const chart = chartMap.get(chartId); - if (chart) { - addLink(chart.slice_name, dashboard.title); + const chartKey = chartKeys.get(chartId); + if (chartKey) { + addLink(chartKey, dashKey); } }); } @@ -437,68 +464,70 @@ const LineageView: FC = ({ lineageResource, entityType }) => { } else if (entityType === 'chart' && 'chart' in data) { const { chart, upstream, downstream } = data as ChartLineage; + const chartKey = nodeKey('chart', chart.id, chart.slice_name); // Add current chart node (center) - label inside - addNode(chart.slice_name, theme.colorPrimary, 'inside'); + addNode(chartKey, chart.slice_name, theme.colorPrimary, 'inside'); // Add upstream dataset - label on left if (upstream?.dataset) { - addNode(upstream.dataset.name, theme.colorInfo, 'left'); - addLink(upstream.dataset.name, chart.slice_name); + const datasetKey = nodeKey( + 'dataset', + upstream.dataset.id, + upstream.dataset.name, + ); + addNode(datasetKey, upstream.dataset.name, theme.colorInfo, 'left'); + addLink(datasetKey, chartKey); // Add upstream database - label on left if (upstream.database) { - addNode(upstream.database.database_name, theme.colorWarning, 'left'); - addLink(upstream.database.database_name, upstream.dataset.name); + const dbKey = nodeKey( + 'database', + upstream.database.id, + upstream.database.database_name, + ); + addNode( + dbKey, + upstream.database.database_name, + theme.colorWarning, + 'left', + ); + addLink(dbKey, datasetKey); } } // Add downstream dashboards - label on right if (downstream?.dashboards?.result) { downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { - addNode(dashboard.title, theme.colorSuccess, 'right'); - addLink(chart.slice_name, dashboard.title); + const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title); + addNode(dashKey, dashboard.title, theme.colorSuccess, 'right'); + addLink(chartKey, dashKey); }); } } else if (entityType === 'dashboard' && 'dashboard' in data) { const { dashboard, upstream } = data as DashboardLineage; + const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title); // Add current dashboard node (right) - label inside - addNode(dashboard.title, theme.colorPrimary, 'inside'); + addNode(dashKey, dashboard.title, theme.colorPrimary, 'inside'); - // First pass: detect duplicate chart names - const chartNameCounts = new Map(); + // Add upstream charts - label on left + const chartKeys = new Map(); if (upstream?.charts?.result) { upstream.charts.result.forEach((chart: ChartEntity) => { - const count = chartNameCounts.get(chart.slice_name) || 0; - chartNameCounts.set(chart.slice_name, count + 1); + const chartKey = nodeKey('chart', chart.id, chart.slice_name); + chartKeys.set(chart.id, chartKey); + addNode(chartKey, chart.slice_name, theme.colorInfo, 'left'); + addLink(chartKey, dashKey); }); } - // Create a map of chart id to chart for easy lookup - const chartMap = new Map(); - const chartNodeNames = new Map(); // Map chart ID to its node name - if (upstream?.charts?.result) { - upstream.charts.result.forEach((chart: ChartEntity) => { - chartMap.set(chart.id, chart); - // Only append ID if there are duplicate names - const hasDuplicate = (chartNameCounts.get(chart.slice_name) || 0) > 1; - const chartNodeName = hasDuplicate - ? `${chart.slice_name} (#${chart.id})` - : chart.slice_name; - chartNodeNames.set(chart.id, chartNodeName); - // Charts are upstream - label on left - addNode(chartNodeName, theme.colorInfo, 'left'); - addLink(chartNodeName, dashboard.title); - }); - } - - // Create a map of dataset id to dataset for easy lookup - const datasetMap = new Map(); + // Add upstream datasets - label on left + const datasetKeys = new Map(); if (upstream?.datasets?.result) { upstream.datasets.result.forEach(dataset => { - datasetMap.set(dataset.id, dataset); - // Datasets are upstream - label on left - addNode(dataset.name, theme.colorSuccess, 'left'); + const datasetKey = nodeKey('dataset', dataset.id, dataset.name); + datasetKeys.set(dataset.id, datasetKey); + addNode(datasetKey, dataset.name, theme.colorSuccess, 'left'); }); } @@ -506,10 +535,10 @@ const LineageView: FC = ({ lineageResource, entityType }) => { if (upstream?.charts?.result) { upstream.charts.result.forEach((chart: ChartEntity) => { if (chart.dataset_id) { - const dataset = datasetMap.get(chart.dataset_id); - const chartNodeName = chartNodeNames.get(chart.id); - if (dataset && chartNodeName) { - addLink(dataset.name, chartNodeName); + const datasetKey = datasetKeys.get(chart.dataset_id); + const chartKey = chartKeys.get(chart.id); + if (datasetKey && chartKey) { + addLink(datasetKey, chartKey); } } }); @@ -518,14 +547,21 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add upstream databases and link to their specific datasets if (upstream?.databases?.result) { upstream.databases.result.forEach(database => { - // Databases are upstream - label on left - addNode(database.database_name, theme.colorWarning, 'left'); + const dbKey = nodeKey( + 'database', + database.id, + database.database_name, + ); + addNode(dbKey, database.database_name, theme.colorWarning, 'left'); // Link databases to datasets that belong to them using database_id if (upstream.datasets?.result) { upstream.datasets.result.forEach(dataset => { if (dataset.database_id === database.id) { - addLink(database.database_name, dataset.name); + const datasetKey = datasetKeys.get(dataset.id); + if (datasetKey) { + addLink(dbKey, datasetKey); + } } }); } @@ -628,9 +664,11 @@ const LineageView: FC = ({ lineageResource, entityType }) => { - {selectedNode.type.charAt(0).toUpperCase() + - selectedNode.type.slice(1)}{' '} - Details + {t( + '%s Details', + selectedNode.type.charAt(0).toUpperCase() + + selectedNode.type.slice(1), + )} {(selectedNode.type === 'dashboard' || diff --git a/superset-frontend/src/hooks/apiResources/apiResources.ts b/superset-frontend/src/hooks/apiResources/apiResources.ts index 04166db1d32..ec6894128d4 100644 --- a/superset-frontend/src/hooks/apiResources/apiResources.ts +++ b/superset-frontend/src/hooks/apiResources/apiResources.ts @@ -86,6 +86,7 @@ const initialState: LoadingState = { */ export function useApiResourceFullBody( endpoint: string, + skip = false, ): Resource { const [resource, setResource] = useState>(initialState); const cancelRef = useRef<() => void>(() => {}); @@ -98,6 +99,12 @@ export function useApiResourceFullBody( // when this effect runs, the endpoint has changed. // cancel any current calls so that state doesn't get messed up. cancelRef.current(); + + // Allow callers to opt out of fetching (e.g. when the identifier isn't + // known yet) so we don't fire requests against invalid endpoints. + if (skip) { + return undefined; + } let cancelled = false; cancelRef.current = () => { cancelled = true; @@ -132,7 +139,7 @@ export function useApiResourceFullBody( return () => { cancelled = true; }; - }, [endpoint]); + }, [endpoint, skip]); return resource; } @@ -181,9 +188,12 @@ const extractInnerResult = (responseBody: { result: T }) => * * @param endpoint The url where the resource is located. */ -export function useApiV1Resource(endpoint: string): Resource { +export function useApiV1Resource( + endpoint: string, + skip = false, +): Resource { return useTransformedResource( - useApiResourceFullBody<{ result: RESULT }>(endpoint), + useApiResourceFullBody<{ result: RESULT }>(endpoint, skip), extractInnerResult, ); } diff --git a/superset-frontend/src/hooks/apiResources/lineage.ts b/superset-frontend/src/hooks/apiResources/lineage.ts index 87011a2f7f6..56e7a7a6a80 100644 --- a/superset-frontend/src/hooks/apiResources/lineage.ts +++ b/superset-frontend/src/hooks/apiResources/lineage.ts @@ -112,23 +112,40 @@ export type DashboardLineage = { downstream: null; }; +// A missing/empty identifier means we have nothing to fetch yet; skip the +// request so we never hit invalid endpoints like `/api/v1/chart//lineage`. +const isEmptyId = (idOrUuid: string | number): boolean => + idOrUuid === '' || idOrUuid == null; + /** * Hook to fetch lineage data for a dataset * @param idOrUuid Dataset ID or UUID + * @param skip When true, defers the request (e.g. until the tab is active) */ -export const useDatasetLineage = (idOrUuid: string | number) => - useApiV1Resource(`/api/v1/dataset/${idOrUuid}/lineage`); +export const useDatasetLineage = (idOrUuid: string | number, skip = false) => + useApiV1Resource( + `/api/v1/dataset/${idOrUuid}/lineage`, + skip || isEmptyId(idOrUuid), + ); /** * Hook to fetch lineage data for a chart * @param idOrUuid Chart ID or UUID + * @param skip When true, defers the request (e.g. until the tab is active) */ -export const useChartLineage = (idOrUuid: string | number) => - useApiV1Resource(`/api/v1/chart/${idOrUuid}/lineage`); +export const useChartLineage = (idOrUuid: string | number, skip = false) => + useApiV1Resource( + `/api/v1/chart/${idOrUuid}/lineage`, + skip || isEmptyId(idOrUuid), + ); /** * Hook to fetch lineage data for a dashboard * @param idOrSlug Dashboard ID or slug + * @param skip When true, defers the request (e.g. until the tab is active) */ -export const useDashboardLineage = (idOrSlug: string | number) => - useApiV1Resource(`/api/v1/dashboard/${idOrSlug}/lineage`); +export const useDashboardLineage = (idOrSlug: string | number, skip = false) => + useApiV1Resource( + `/api/v1/dashboard/${idOrSlug}/lineage`, + skip || isEmptyId(idOrSlug), + ); diff --git a/superset/charts/api.py b/superset/charts/api.py index c977b8f0d0b..6a376a6cfac 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -588,9 +588,13 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi): upstream["dataset"] = None upstream["database"] = None - # Get downstream (dashboards) information + # Get downstream (dashboards) information, filtered by the current + # user's permissions so lineage never exposes dashboards the user + # cannot access. dashboards = [] for dashboard in chart.dashboards: + if not security_manager.can_access_dashboard(dashboard): + continue dashboards.append( { "id": dashboard.id, diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index c18c18c3a86..288f7a807c3 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -564,6 +564,7 @@ class DashboardRestApi( DashboardCacheScreenshotResponseSchema, DashboardCopySchema, DashboardGetResponseSchema, + DashboardLineageResponseSchema, DashboardDatasetSchema, DashboardExportXlsxPostSchema, DashboardExportXlsxResponseSchema, @@ -743,26 +744,38 @@ class DashboardRestApi( } ) - # Collect dataset information + # Collect dataset information. Schema/table/database details are + # only exposed to users who can access the underlying datasource; + # otherwise they are redacted so lineage never leaks datasource + # internals (the dataset id/name are kept so the graph still + # renders). dataset = chart.datasource if dataset and dataset.id not in dataset_map: + can_access = security_manager.can_access_datasource(dataset) dataset_map[dataset.id] = { "id": dataset.id, "name": dataset.name, - "database_id": dataset.database_id, - "database_name": dataset.database.database_name - if dataset.database - else None, - "schema": dataset.schema, - "table_name": dataset.table_name, + "database_id": dataset.database_id if can_access else None, + "database_name": ( + dataset.database.database_name + if can_access and dataset.database + else None + ), + "schema": dataset.schema if can_access else None, + "table_name": dataset.table_name if can_access else None, "chart_ids": [], } if dataset and dataset.id in dataset_map: dataset_map[dataset.id]["chart_ids"].append(chart.id) - # Collect database information - if dataset and dataset.database and dataset.database.id not in database_map: + # Collect database information, only for accessible datasources + if ( + dataset + and security_manager.can_access_datasource(dataset) + and dataset.database + and dataset.database.id not in database_map + ): database_map[dataset.database.id] = { "id": dataset.database.id, "database_name": dataset.database.database_name, diff --git a/superset/datasets/api.py b/superset/datasets/api.py index a6d9bc7489a..27717d9b1eb 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -1163,10 +1163,18 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi): # Get downstream (charts and dashboards) information related_data = DatasetDAO.get_related_objects(dataset.id) - # Build chart information with dashboard IDs + # Build chart information with dashboard IDs, filtering both the charts + # and their linked dashboards by the current user's permissions so + # lineage never exposes assets the user cannot access. charts = [] for chart in related_data["charts"]: - dashboard_ids = [d.id for d in chart.dashboards] + if not security_manager.can_access_chart(chart): + continue + dashboard_ids = [ + d.id + for d in chart.dashboards + if security_manager.can_access_dashboard(d) + ] charts.append( { "id": chart.id, @@ -1179,6 +1187,8 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi): # Build dashboard information with chart IDs dashboards = [] for dashboard in related_data["dashboards"]: + if not security_manager.can_access_dashboard(dashboard): + continue chart_ids = [ chart.id for chart in dashboard.slices diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index b41d84eaf63..d1f3c5f27d4 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -2686,8 +2686,8 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase): data = json.loads(rv.data.decode("utf-8")) - # Assert the entire response matches expected structure - assert data == expected + # The lineage payload is wrapped under "result" + assert data["result"] == expected def test_get_chart_lineage_not_found(self): """ diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index f62f12adc17..224d0f0c086 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -4493,8 +4493,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa data = json.loads(rv.data.decode("utf-8")) - # Assert the entire response matches expected structure - assert data == expected + # The lineage payload is wrapped under "result" + assert data["result"] == expected def test_get_dashboard_lineage_not_found(self): """ diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 8daa1c4dcd5..429a406f3d5 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -3840,8 +3840,8 @@ class TestDatasetApi(SupersetTestCase): data = json.loads(rv.data.decode("utf-8")) - # Assert the entire response matches expected structure - assert data == expected + # The lineage payload is wrapped under "result" + assert data["result"] == expected def test_get_dataset_lineage_not_found(self): """