fix(ui): Disable ability to export data when user does not have the correct permission (#28429)

This commit is contained in:
Ed Jannoo 2024-06-20 16:26:51 +01:00 committed by GitHub
parent 313ee596f5
commit 70f6f5f3ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 253 additions and 30 deletions

View File

@ -446,4 +446,82 @@ describe('ResultSet', () => {
}),
).toBe(null);
});
test('should allow download as CSV when user has permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_csv', 'SQLLab']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('export-csv-button')).toBeInTheDocument();
});
test('should not allow download as CSV when user does not have permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('export-csv-button')).not.toBeInTheDocument();
});
test('should allow copy to clipboard when user has permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_csv', 'SQLLab']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('copy-to-clipboard-button')).toBeInTheDocument();
});
test('should not allow copy to clipboard when user does not have permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument();
});
});

View File

@ -78,6 +78,7 @@ import {
LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV,
} from 'src/logger/LogUtils';
import Icons from 'src/components/Icons';
import { findPermission } from 'src/utils/findPermission';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
@ -309,6 +310,12 @@ const ResultSet = ({
schema: query?.schema,
};
const canExportData = findPermission(
'can_export_csv',
'SQLLab',
user?.roles,
);
return (
<ResultSetControls>
<SaveDatasetModal
@ -328,29 +335,35 @@ const ResultSet = ({
onClick={createExploreResultsOnClick}
/>
)}
{csv && (
{csv && canExportData && (
<Button
buttonSize="small"
href={getExportCsvUrl(query.id)}
data-test="export-csv-button"
onClick={() => logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {})}
>
<i className="fa fa-file-text-o" /> {t('Download to CSV')}
</Button>
)}
<CopyToClipboard
text={prepareCopyToClipboardTabularData(data, columns)}
wrapped={false}
copyNode={
<Button buttonSize="small">
<i className="fa fa-clipboard" /> {t('Copy to Clipboard')}
</Button>
}
hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
{canExportData && (
<CopyToClipboard
text={prepareCopyToClipboardTabularData(data, columns)}
wrapped={false}
copyNode={
<Button
buttonSize="small"
data-test="copy-to-clipboard-button"
>
<i className="fa fa-clipboard" /> {t('Copy to Clipboard')}
</Button>
}
hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
)}
</ResultSetButtons>
{search && (
<input

View File

@ -97,6 +97,9 @@ const ChartContextMenu = (
const canDatasourceSamples = useSelector((state: RootState) =>
findPermission('can_samples', 'Datasource', state.user?.roles),
);
const canDownload = useSelector((state: RootState) =>
findPermission('can_csv', 'Superset', state.user?.roles),
);
const canDrill = useSelector((state: RootState) =>
findPermission('can_drill', 'Dashboard', state.user?.roles),
);
@ -256,6 +259,7 @@ const ChartContextMenu = (
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
canDownload={canDownload}
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
{...(additionalConfig?.drillBy || {})}

View File

@ -74,6 +74,7 @@ const renderMenu = ({
<DrillByMenuItems
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
canDownload
open
{...rest}
/>

View File

@ -74,6 +74,7 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void;
openNewModal?: boolean;
excludedColumns?: Column[];
canDownload: boolean;
open: boolean;
}
@ -105,6 +106,7 @@ export const DrillByMenuItems = ({
onClick = () => {},
excludedColumns,
openNewModal = true,
canDownload,
open,
...rest
}: DrillByMenuItemsProps) => {
@ -344,6 +346,7 @@ export const DrillByMenuItems = ({
formData={formData}
onHideModal={closeModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}
</>

View File

@ -86,6 +86,7 @@ const renderModal = async (
onHideModal={() => setShowModal(false)}
dataset={dataset}
drillByConfig={{ groupbyFieldName: 'groupby', filters: [] }}
canDownload
{...modalProps}
/>
)}

View File

@ -151,6 +151,7 @@ export interface DrillByModalProps {
drillByConfig: Required<ContextMenuFilters>['drillBy'];
formData: BaseFormData & { [key: string]: any };
onHideModal: () => void;
canDownload: boolean;
}
type DrillByConfigs = (ContextMenuFilters['drillBy'] & { column?: Column })[];
@ -161,6 +162,7 @@ export default function DrillByModal({
drillByConfig,
formData,
onHideModal,
canDownload,
}: DrillByModalProps) {
const dispatch = useDispatch();
const theme = useTheme();
@ -200,6 +202,7 @@ export default function DrillByModal({
const resultsTable = useResultsTableView(
chartDataResult,
formData.datasource,
canDownload,
);
const [currentFormData, setCurrentFormData] = useState(formData);

View File

@ -65,7 +65,7 @@ const MOCK_CHART_DATA_RESULT = [
test('Displays results table for 1 query', () => {
const { result } = renderHook(() =>
useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table'),
useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table', true),
);
render(result.current, { useRedux: true });
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
@ -76,7 +76,7 @@ test('Displays results table for 1 query', () => {
test('Displays results for 2 queries', async () => {
const { result } = renderHook(() =>
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table'),
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
);
render(result.current, { useRedux: true });
const getActiveTabElement = () =>

View File

@ -33,6 +33,7 @@ const PaginationContainer = styled.div`
export const useResultsTableView = (
chartDataResult: QueryData[] | undefined,
datasourceId: string,
canDownload: boolean,
) => {
if (!isDefined(chartDataResult)) {
return <div />;
@ -48,6 +49,7 @@ export const useResultsTableView = (
dataSize={DATA_SIZE}
datasourceId={datasourceId}
isVisible
canDownload={canDownload}
/>
</PaginationContainer>
);
@ -65,6 +67,7 @@ export const useResultsTableView = (
dataSize={DATA_SIZE}
datasourceId={datasourceId}
isVisible
canDownload={canDownload}
/>
</PaginationContainer>
</Tabs.TabPane>

View File

@ -809,6 +809,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
dataSize={20}
isRequest
isVisible
canDownload={!!props.supersetCanCSV}
/>
}
/>

View File

@ -88,6 +88,7 @@ export const DataTablesPane = ({
ownState,
errorMessage,
actions,
canDownload,
}: DataTablesPaneProps) => {
const theme = useTheme();
const [activeTabKey, setActiveTabKey] = useState<string>(ResultTypes.Results);
@ -198,6 +199,7 @@ export const DataTablesPane = ({
isRequest: isRequest.results,
actions,
isVisible: ResultTypes.Results === activeTabKey,
canDownload,
}).map((pane, idx) => {
if (idx === 0) {
return (
@ -235,6 +237,7 @@ export const DataTablesPane = ({
isRequest={isRequest.samples}
actions={actions}
isVisible={ResultTypes.Samples === activeTabKey}
canDownload={canDownload}
/>
</Tabs.TabPane>
</Tabs>

View File

@ -49,6 +49,7 @@ export const TableControls = ({
columnTypes,
rowcount,
isLoading,
canDownload,
}: TableControlsProps) => {
const originalTimeColumns = getTimeColumns(datasourceId);
const formattedTimeColumns = zip<string, GenericDataType>(
@ -76,7 +77,9 @@ export const TableControls = ({
`}
>
<RowCountLabel rowcount={rowcount} loading={isLoading} />
<CopyToClipboardButton data={formattedData} columns={columnNames} />
{canDownload && (
<CopyToClipboardButton data={formattedData} columns={columnNames} />
)}
</div>
</TableControlsWrapper>
);

View File

@ -53,6 +53,7 @@ export const ResultsPaneOnDashboard = ({
actions,
isVisible,
dataSize = 50,
canDownload,
}: ResultsPaneProps) => {
const resultsPanes = useResultsPane({
errorMessage,
@ -63,6 +64,7 @@ export const ResultsPaneOnDashboard = ({
actions,
dataSize,
isVisible,
canDownload,
});
if (resultsPanes.length === 1) {

View File

@ -42,6 +42,7 @@ export const SamplesPane = ({
actions,
dataSize = 50,
isVisible,
canDownload,
}: SamplesPaneProps) => {
const [filterText, setFilterText] = useState('');
const [data, setData] = useState<Record<string, any>[][]>([]);
@ -114,6 +115,7 @@ export const SamplesPane = ({
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
isLoading={isLoading}
canDownload={canDownload}
/>
<Error>{responseError}</Error>
</>
@ -135,6 +137,7 @@ export const SamplesPane = ({
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
isLoading={isLoading}
canDownload={canDownload}
/>
<TableView
columns={columns}

View File

@ -34,6 +34,7 @@ export const SingleQueryResultPane = ({
datasourceId,
dataSize = 50,
isVisible,
canDownload,
}: SingleQueryResultPaneProp) => {
const [filterText, setFilterText] = useState('');
@ -60,6 +61,7 @@ export const SingleQueryResultPane = ({
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
isLoading={false}
canDownload={canDownload}
/>
<TableView
columns={columns}

View File

@ -47,6 +47,7 @@ export const useResultsPane = ({
actions,
isVisible,
dataSize = 50,
canDownload,
}: ResultsPaneProps): ReactElement[] => {
const metadata = getChartMetadataRegistry().get(
queryFormData?.viz_type || queryFormData?.vizType,
@ -124,6 +125,7 @@ export const useResultsPane = ({
datasourceId={queryFormData.datasource}
onInputChange={() => {}}
isLoading={false}
canDownload={canDownload}
/>
<Error>{responseError}</Error>
</>
@ -149,6 +151,7 @@ export const useResultsPane = ({
datasourceId={queryFormData.datasource}
key={idx}
isVisible={isVisible}
canDownload={canDownload}
/>
));
};

View File

@ -114,6 +114,34 @@ describe('DataTablesPane', () => {
fetchMock.restore();
});
test('Should not allow copy data table content when canDownload=false', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
{
result: [
{
data: [{ __timestamp: 1230768000000, genre: 'Action' }],
colnames: ['__timestamp', 'genre'],
coltypes: [2, 1],
rowcount: 1,
sql_rowcount: 1,
},
],
},
);
const props = {
...createDataTablesPaneProps(456),
canDownload: false,
};
render(<DataTablesPane {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('1 row')).toBeVisible();
expect(screen.queryByLabelText('Copy')).not.toBeInTheDocument();
fetchMock.restore();
});
test('Search table', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A789%7D',

View File

@ -73,6 +73,7 @@ export const createDataTablesPaneProps = (sliceId: number) =>
chartStatus: 'rendered' as ChartStatus,
onCollapseChange: jest.fn(),
actions: exploreActions,
canDownload: true,
}) as DataTablesPaneProps;
export const createSamplesPaneProps = ({
@ -90,6 +91,7 @@ export const createSamplesPaneProps = ({
queryForce,
isVisible: true,
actions: exploreActions,
canDownload: true,
}) as SamplesPaneProps;
export const createResultsPaneOnDashboardProps = ({
@ -116,4 +118,5 @@ export const createResultsPaneOnDashboardProps = ({
isVisible: true,
actions: exploreActions,
errorMessage,
canDownload: true,
}) as ResultsPaneProps;

View File

@ -40,6 +40,7 @@ export interface DataTablesPaneProps {
onCollapseChange: (isOpen: boolean) => void;
errorMessage?: JSX.Element;
actions: ExploreActions;
canDownload: boolean;
}
export interface ResultsPaneProps {
@ -52,6 +53,7 @@ export interface ResultsPaneProps {
dataSize?: number;
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
isVisible: boolean;
canDownload: boolean;
}
export interface SamplesPaneProps {
@ -62,6 +64,7 @@ export interface SamplesPaneProps {
dataSize?: number;
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
isVisible: boolean;
canDownload: boolean;
}
export interface TableControlsProps {
@ -73,6 +76,7 @@ export interface TableControlsProps {
columnTypes: GenericDataType[];
isLoading: boolean;
rowcount: number;
canDownload: boolean;
}
export interface QueryResultInterface {
@ -88,4 +92,5 @@ export interface SingleQueryResultPaneProp extends QueryResultInterface {
dataSize?: number;
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
isVisible: boolean;
canDownload: boolean;
}

View File

@ -394,11 +394,25 @@ describe('Additional actions tests', () => {
spyExportChart.restore();
});
test('Should export to JSON', async () => {
test('Should not export to JSON if canDownload=false', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.callCount).toBe(0);
spyExportChart.restore();
});
test('Should export to JSON if canDownload=true', async () => {
const props = createProps();
props.canDownload = true;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
@ -407,6 +421,22 @@ describe('Additional actions tests', () => {
expect(spyExportChart.callCount).toBe(1);
});
test('Should not export to pivoted CSV if canDownloadCSV=false and viz_type=pivot_table_v2', async () => {
const props = createProps();
props.chart.latestQueryFormData.viz_type = 'pivot_table_v2';
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText(
'Export to pivoted .CSV',
);
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(0);
});
test('Should export to pivoted CSV if canDownloadCSV=true and viz_type=pivot_table_v2', async () => {
const props = createProps();
props.canDownload = true;
@ -423,5 +453,31 @@ describe('Additional actions tests', () => {
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(1);
});
test('Should not export to Excel if canDownload=false', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportExcelElement = await screen.findByText('Export to Excel');
userEvent.click(exportExcelElement);
expect(spyExportChart.callCount).toBe(0);
spyExportChart.restore();
});
test('Should export to Excel if canDownload=true', async () => {
const props = createProps();
props.canDownload = true;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportExcelElement = await screen.findByText('Export to Excel');
userEvent.click(exportExcelElement);
expect(spyExportChart.callCount).toBe(1);
});
});
});

View File

@ -137,6 +137,7 @@ const ExploreChartPanel = ({
standalone,
chartIsStale,
chartAlert,
can_download: canDownload,
}) => {
const theme = useTheme();
const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR;
@ -449,6 +450,7 @@ const ExploreChartPanel = ({
chartStatus={chart.chartStatus}
errorMessage={errorMessage}
actions={actions}
canDownload={canDownload}
/>
</Split>
{showDatasetModal && (

View File

@ -173,22 +173,26 @@ export const useExploreAdditionalActionsMenu = (
const exportJson = useCallback(
() =>
exportChart({
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
}),
[latestQueryFormData],
canDownloadCSV
? exportChart({
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
})
: null,
[canDownloadCSV, latestQueryFormData],
);
const exportExcel = useCallback(
() =>
exportChart({
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'xlsx',
}),
[latestQueryFormData],
canDownloadCSV
? exportChart({
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'xlsx',
})
: null,
[canDownloadCSV, latestQueryFormData],
);
const copyLink = useCallback(async () => {
@ -350,6 +354,7 @@ export const useExploreAdditionalActionsMenu = (
<Menu.Item
key={MENU_KEYS.EXPORT_TO_JSON}
icon={<Icons.FileOutlined css={iconReset} />}
disabled={!canDownloadCSV}
>
{t('Export to .JSON')}
</Menu.Item>
@ -362,6 +367,7 @@ export const useExploreAdditionalActionsMenu = (
<Menu.Item
key={MENU_KEYS.EXPORT_TO_XLSX}
icon={<Icons.FileOutlined css={iconReset} />}
disabled={!canDownloadCSV}
>
{t('Export to Excel')}
</Menu.Item>