mirror of
https://github.com/apache/superset.git
synced 2026-06-01 05:39:17 +00:00
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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user