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:
@@ -41,12 +41,20 @@ import {
|
||||
queryWithNoQueryLimit,
|
||||
failedQueryWithFrontendTimeoutErrors,
|
||||
} from 'src/SqlLab/fixtures';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import {
|
||||
registerToolbarAction,
|
||||
cleanupExtensions,
|
||||
} from 'spec/helpers/extensionTestHelpers';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
jest.mock('src/components/ErrorMessage', () => ({
|
||||
ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
|
||||
}));
|
||||
@@ -551,7 +559,9 @@ describe('ResultSet', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(queryByTestId('export-csv-button')).not.toBeInTheDocument();
|
||||
const csvButton = queryByTestId('export-csv-button');
|
||||
expect(csvButton).toBeInTheDocument();
|
||||
expect(csvButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should allow copy to clipboard when user has permission to export data', async () => {
|
||||
@@ -590,7 +600,9 @@ describe('ResultSet', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument();
|
||||
const clipboardButton = queryByTestId('copy-to-clipboard-button');
|
||||
expect(clipboardButton).toBeInTheDocument();
|
||||
expect(clipboardButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should include sqlEditorImmutableId in query object when fetching results', async () => {
|
||||
@@ -760,6 +772,213 @@ describe('ResultSet', () => {
|
||||
},
|
||||
);
|
||||
|
||||
test('should show CSV button with granular can_export_data permission when flag is ON', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
|
||||
);
|
||||
const { queryByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [['can_export_data', 'Superset']],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const csvButton = queryByTestId('export-csv-button');
|
||||
expect(csvButton).toBeInTheDocument();
|
||||
expect(csvButton).toBeEnabled();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('should disable CSV button when granular flag is ON and user lacks can_export_data', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
|
||||
);
|
||||
const { queryByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const csvButton = queryByTestId('export-csv-button');
|
||||
expect(csvButton).toBeInTheDocument();
|
||||
expect(csvButton).toBeDisabled();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('should show clipboard button with granular can_copy_clipboard permission when flag is ON', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
|
||||
);
|
||||
const { queryByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [['can_copy_clipboard', 'Superset']],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const clipboardButton = queryByTestId('copy-to-clipboard-button');
|
||||
expect(clipboardButton).toBeInTheDocument();
|
||||
expect(clipboardButton).toBeEnabled();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('should disable clipboard button when granular flag is ON and user lacks can_copy_clipboard', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
|
||||
);
|
||||
const { queryByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [['can_export_data', 'Superset']],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const clipboardButton = queryByTestId('copy-to-clipboard-button');
|
||||
expect(clipboardButton).toBeInTheDocument();
|
||||
expect(clipboardButton).toBeDisabled();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('should use legacy can_export_csv for both CSV and clipboard when granular flag is OFF', async () => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
const { queryByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [['can_export_csv', 'SQLLab']],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const csvButton = queryByTestId('export-csv-button');
|
||||
const clipboardButton = queryByTestId('copy-to-clipboard-button');
|
||||
expect(csvButton).toBeInTheDocument();
|
||||
expect(csvButton).toBeEnabled();
|
||||
expect(clipboardButton).toBeInTheDocument();
|
||||
expect(clipboardButton).toBeEnabled();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('disabled CSV button should show permission tooltip when granular flag is ON', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
|
||||
);
|
||||
const { getByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [['can_copy_clipboard', 'Superset']],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const csvButton = getByTestId('export-csv-button');
|
||||
expect(csvButton).toBeDisabled();
|
||||
fireEvent.mouseOver(csvButton.parentElement ?? csvButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("You don't have permission to export data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('disabled clipboard button should show permission tooltip when granular flag is ON', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
|
||||
);
|
||||
const { getByTestId } = setup(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
...user,
|
||||
roles: {
|
||||
sql_lab: [['can_export_data', 'Superset']],
|
||||
},
|
||||
},
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queries[0].id]: queries[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const clipboardButton = getByTestId('copy-to-clipboard-button');
|
||||
expect(clipboardButton).toBeDisabled();
|
||||
fireEvent.mouseOver(clipboardButton.parentElement ?? clipboardButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("You don't have permission to copy to clipboard"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
test('renders contributed toolbar action in results slot', async () => {
|
||||
registerToolbarAction(
|
||||
ViewLocations.sqllab.results,
|
||||
|
||||
@@ -82,7 +82,7 @@ import {
|
||||
LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV,
|
||||
} from 'src/logger/LogUtils';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
|
||||
import { useConfirmModal } from 'src/hooks/useConfirmModal';
|
||||
@@ -172,7 +172,6 @@ const ResultSet = ({
|
||||
defaultQueryLimit,
|
||||
useFixedHeight = false,
|
||||
}: ResultSetProps) => {
|
||||
const user = useSelector(({ user }: SqlLabRootState) => user, shallowEqual);
|
||||
const streamingThreshold = useSelector(
|
||||
(state: SqlLabRootState) =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD || 1000,
|
||||
@@ -227,6 +226,10 @@ const ResultSet = ({
|
||||
[query.results?.expanded_columns],
|
||||
);
|
||||
|
||||
const {
|
||||
canExportDataSqlLab: canExportData,
|
||||
canCopyClipboardSqlLab: canCopyClipboard,
|
||||
} = usePermissions();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
@@ -361,12 +364,6 @@ const ResultSet = ({
|
||||
schema: query?.schema,
|
||||
};
|
||||
|
||||
const canExportData = findPermission(
|
||||
'can_export_csv',
|
||||
'SQLLab',
|
||||
user?.roles,
|
||||
);
|
||||
|
||||
const handleDownloadCsv = (event: React.MouseEvent<HTMLElement>) => {
|
||||
logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
|
||||
|
||||
@@ -396,19 +393,26 @@ const ResultSet = ({
|
||||
onClick={createExploreResultsOnClick}
|
||||
/>
|
||||
)}
|
||||
{csv && canExportData && (
|
||||
{csv && (
|
||||
<Button
|
||||
buttonSize="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
icon={<Icons.DownloadOutlined iconSize="m" />}
|
||||
tooltip={t('Download to CSV')}
|
||||
tooltip={
|
||||
!canExportData
|
||||
? t("You don't have permission to export data")
|
||||
: t('Download to CSV')
|
||||
}
|
||||
aria-label={t('Download to CSV')}
|
||||
{...(!shouldUseStreamingExport() && {
|
||||
href: getExportCsvUrl(query.id),
|
||||
})}
|
||||
disabled={!canExportData}
|
||||
{...(canExportData &&
|
||||
!shouldUseStreamingExport() && {
|
||||
href: getExportCsvUrl(query.id),
|
||||
})}
|
||||
data-test="export-csv-button"
|
||||
onClick={e => {
|
||||
if (!canExportData) return;
|
||||
const useStreaming = shouldUseStreamingExport();
|
||||
|
||||
if (useStreaming) {
|
||||
@@ -427,30 +431,38 @@ const ResultSet = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canExportData && (
|
||||
<CopyToClipboard
|
||||
text={prepareCopyToClipboardTabularData(
|
||||
data,
|
||||
columns.map(c => c.column_name),
|
||||
)}
|
||||
wrapped={false}
|
||||
copyNode={
|
||||
<Button
|
||||
buttonSize="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
tooltip={t('Copy to Clipboard')}
|
||||
aria-label={t('Copy to Clipboard')}
|
||||
data-test="copy-to-clipboard-button"
|
||||
/>
|
||||
}
|
||||
hideTooltip
|
||||
onCopyEnd={() =>
|
||||
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<CopyToClipboard
|
||||
text={
|
||||
canCopyClipboard
|
||||
? prepareCopyToClipboardTabularData(
|
||||
data,
|
||||
columns.map(c => c.column_name),
|
||||
)
|
||||
: ''
|
||||
}
|
||||
disabled={!canCopyClipboard}
|
||||
wrapped={false}
|
||||
copyNode={
|
||||
<Button
|
||||
buttonSize="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
tooltip={
|
||||
!canCopyClipboard
|
||||
? t("You don't have permission to copy to clipboard")
|
||||
: t('Copy to Clipboard')
|
||||
}
|
||||
aria-label={t('Copy to Clipboard')}
|
||||
disabled={!canCopyClipboard}
|
||||
data-test="copy-to-clipboard-button"
|
||||
/>
|
||||
}
|
||||
hideTooltip
|
||||
onCopyEnd={() =>
|
||||
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -362,7 +362,7 @@ export const queryWithNoQueryLimit = {
|
||||
},
|
||||
{
|
||||
is_dttm: false,
|
||||
name: 'gender',
|
||||
column_name: 'gender',
|
||||
type: 'STRING',
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user