feat(security): add granular export controls - Phase 2 + 3 (#38581)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
Co-authored-by: Daniel Vaz Gaspar <danielvazgaspar@gmail.com>
This commit is contained in:
Hugh A. Miles II
2026-04-15 10:24:59 -04:00
committed by GitHub
parent 411f769896
commit b76080e291
24 changed files with 1092 additions and 92 deletions

View File

@@ -54,3 +54,31 @@ test('Should copy to clipboard', async () => {
// @ts-expect-error
global.navigator.clipboard = originalClipboard;
});
test('Should not copy to clipboard when disabled', async () => {
const callback = jest.fn();
document.execCommand = callback;
const originalClipboard = { ...global.navigator.clipboard };
// @ts-expect-error
global.navigator.clipboard = { write: callback, writeText: callback };
render(
<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} disabled />,
{
useRedux: true,
},
);
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveAttribute('aria-disabled', 'true');
userEvent.click(copyButton);
await waitFor(() => {
expect(callback).not.toHaveBeenCalled();
});
jest.resetAllMocks();
// @ts-expect-error
global.navigator.clipboard = originalClipboard;
});

View File

@@ -58,21 +58,30 @@ export const CopyButton = styled(Button)`
export const CopyToClipboardButton = ({
data,
columns,
disabled = false,
}: {
data?: TabularDataRow[];
columns?: string[];
disabled?: boolean;
}) => (
<CopyToClipboard
text={
data && columns ? prepareCopyToClipboardTabularData(data, columns) : ''
!disabled && data && columns
? prepareCopyToClipboardTabularData(data, columns)
: ''
}
disabled={disabled}
wrapped={false}
copyNode={
<Icons.CopyOutlined
iconSize="l"
aria-label={t('Copy')}
aria-disabled={disabled}
role="button"
tabIndex={disabled ? -1 : 0}
css={css`
opacity: ${disabled ? 0.3 : 1};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
&.anticon > * {
line-height: 0;
}

View File

@@ -18,8 +18,10 @@
*/
import { styled, css } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import { t } from '@apache-superset/core/translation';
import { useMemo } from 'react';
import { zip } from 'lodash';
import { Tooltip } from '@superset-ui/core/components';
import { Select } from 'antd';
import {
CopyToClipboardButton,
@@ -28,6 +30,7 @@ import {
import { applyFormattingToTabularData } from 'src/utils/common';
import { getTimeColumns } from 'src/explore/components/DataTableControl/utils';
import RowCountLabel from 'src/components/RowCountLabel';
import { usePermissions } from 'src/hooks/usePermissions';
import { TableControlsProps } from '../types';
export const ROW_LIMIT_OPTIONS = [
@@ -60,7 +63,6 @@ export const TableControls = ({
columnTypes,
rowcount,
isLoading,
canDownload,
rowLimit,
rowLimitOptions,
onRowLimitChange,
@@ -82,6 +84,7 @@ export const TableControls = ({
() => applyFormattingToTabularData(data, formattedTimeColumns),
[data, formattedTimeColumns],
);
const { canCopyClipboard: copyEnabled } = usePermissions();
return (
<TableControlsWrapper>
<FilterInput onChangeHandler={onInputChange} shouldFocus />
@@ -106,8 +109,18 @@ export const TableControls = ({
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
{canDownload && (
{copyEnabled ? (
<CopyToClipboardButton data={formattedData} columns={columnNames} />
) : (
<Tooltip title={t("You don't have permission to copy to clipboard")}>
<span>
<CopyToClipboardButton
data={formattedData}
columns={columnNames}
disabled
/>
</span>
</Tooltip>
)}
</div>
</TableControlsWrapper>

View File

@@ -104,23 +104,36 @@ describe('DataTablesPane', () => {
],
},
);
// @ts-expect-error
global.featureFlags = {
[FeatureFlag.GranularExportControls]: true,
};
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
const props = createDataTablesPaneProps(456);
render(<DataTablesPane {...props} />, {
useRedux: true,
initialState: {
user: {
roles: {
gamma: [['can_copy_clipboard', 'Superset']],
},
},
},
});
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('1 row')).toBeVisible();
userEvent.click(screen.getByLabelText('Copy'));
await userEvent.click(screen.getByLabelText('Copy'));
expect(copyToClipboardSpy).toHaveBeenCalledTimes(1);
const value = await copyToClipboardSpy.mock.calls[0][0]();
expect(value).toBe('__timestamp\tgenre\n2009-01-01 00:00:00\tAction\n');
copyToClipboardSpy.mockRestore();
// @ts-expect-error
global.featureFlags = {};
fetchMock.clearHistory().removeRoutes();
});
test('Should not allow copy data table content when canDownload=false', async () => {
test('Should not allow copy data table content without clipboard permission', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
{
@@ -135,16 +148,38 @@ describe('DataTablesPane', () => {
],
},
);
// @ts-expect-error
global.featureFlags = {
[FeatureFlag.GranularExportControls]: true,
};
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
const props = {
...createDataTablesPaneProps(456),
canDownload: false,
canDownload: true,
};
render(<DataTablesPane {...props} />, {
useRedux: true,
initialState: {
user: {
roles: {
gamma: [['can_export_data', 'Superset']],
},
},
},
});
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('1 row')).toBeVisible();
expect(screen.queryByLabelText('Copy')).not.toBeInTheDocument();
const copyButton = screen.getByLabelText('Copy');
expect(copyButton).toHaveAttribute('aria-disabled', 'true');
await userEvent.hover(copyButton);
expect(
await screen.findByText("You don't have permission to copy to clipboard"),
).toBeInTheDocument();
await userEvent.click(copyButton);
expect(copyToClipboardSpy).not.toHaveBeenCalled();
copyToClipboardSpy.mockRestore();
// @ts-expect-error
global.featureFlags = {};
fetchMock.clearHistory().removeRoutes();
});

View File

@@ -766,6 +766,7 @@ describe('Additional actions tests', () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: { explore: { can_export_image: true } },
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
@@ -967,7 +968,10 @@ describe('Additional actions tests', () => {
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: { explore: { can_export_image: true } },
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));

View File

@@ -52,6 +52,7 @@ import downloadAsImage from 'src/utils/downloadAsImage';
import { getChartPermalink } from 'src/utils/urlUtils';
import copyTextToClipboard from 'src/utils/copy';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
import { logEvent } from 'src/logger/actions';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
@@ -174,6 +175,7 @@ interface ExploreState {
charts?: Record<number, ChartState>;
explore?: ExploreSlice & {
chartStates?: Record<number, JsonObject>;
can_export_image?: boolean;
};
common?: {
conf?: {
@@ -231,6 +233,36 @@ export const useExploreAdditionalActionsMenu = (
: undefined;
},
);
const canExportImage = useSelector<ExploreState, boolean>(
state => state.explore?.can_export_image ?? false,
);
const dataExportDisabled = !canDownloadCSV;
const imageExportDisabled = !canExportImage;
const dataExportLabel = (text: string) =>
dataExportDisabled ? (
<span>
{text}
<MenuItemTooltip
title={t("You don't have permission to export data")}
/>
</span>
) : (
text
);
const imageExportLabel = (text: string) =>
imageExportDisabled ? (
<span>
{text}
<MenuItemTooltip
title={t("You don't have permission to export images")}
/>
</span>
) : (
text
);
// Streaming export state and handlers
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
@@ -644,7 +676,7 @@ export const useExploreAdditionalActionsMenu = (
allDataChildren.push(
{
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to original .CSV'),
label: dataExportLabel(t('Export to original .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -660,7 +692,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED,
label: t('Export to pivoted .CSV'),
label: dataExportLabel(t('Export to pivoted .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -676,7 +708,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX,
label: t('Export to Pivoted Excel'),
label: dataExportLabel(t('Export to Pivoted Excel')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -698,7 +730,7 @@ export const useExploreAdditionalActionsMenu = (
} else {
allDataChildren.push({
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to .CSV'),
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -717,7 +749,7 @@ export const useExploreAdditionalActionsMenu = (
allDataChildren.push(
{
key: MENU_KEYS.EXPORT_TO_JSON,
label: t('Export to .JSON'),
label: dataExportLabel(t('Export to .JSON')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -733,8 +765,9 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_ALL_SCREENSHOT,
label: t('Export screenshot (jpeg)'),
label: imageExportLabel(t('Export screenshot (jpeg)')),
icon: <Icons.FileImageOutlined />,
disabled: imageExportDisabled,
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
downloadAsImage(
'.panel-body .chart-container',
@@ -753,7 +786,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_TO_XLSX,
label: t('Export to Excel'),
label: dataExportLabel(t('Export to Excel')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -772,7 +805,7 @@ export const useExploreAdditionalActionsMenu = (
const currentViewChildren = [
{
key: MENU_KEYS.EXPORT_CURRENT_TO_CSV,
label: t('Export to .CSV'),
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -808,7 +841,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_CURRENT_TO_JSON,
label: t('Export to .JSON'),
label: dataExportLabel(t('Export to .JSON')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -837,8 +870,9 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_CURRENT_SCREENSHOT,
label: t('Export screenshot (jpeg)'),
label: imageExportLabel(t('Export screenshot (jpeg)')),
icon: <Icons.FileImageOutlined />,
disabled: imageExportDisabled,
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
downloadAsImage(
'.panel-body .chart-container',
@@ -857,7 +891,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_CURRENT_XLSX,
label: t('Export to Excel'),
label: dataExportLabel(t('Export to Excel')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: async () => {
@@ -1037,6 +1071,7 @@ export const useExploreAdditionalActionsMenu = (
theme.sizeUnit,
ownState,
hasExportCurrentView,
canExportImage,
]);
// Return streaming modal state and handlers for parent to render