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

@@ -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,

View File

@@ -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, {})
}
/>
</>
);

View File

@@ -362,7 +362,7 @@ export const queryWithNoQueryLimit = {
},
{
is_dttm: false,
name: 'gender',
column_name: 'gender',
type: 'STRING',
},
],